From 4f3d6aba2d0905db1e3e6f9c36c799e07a707ca3 Mon Sep 17 00:00:00 2001 From: Marko Ristin-Kaufmann Date: Sun, 1 Nov 2026 19:49:31 +0100 Subject: [PATCH] Check safe integers and make JSON values public We make the de/serialization of JSON-able values public in all SDKs so that the aas-client3.2 implementations can use them, e.g., ``json_value_from_jsonable`` in Python, ``JsonValueFromJsonable`` in Go, ``Deserialize.JsonValueFrom`` in C# and ``JsonValueFrom`` in C++. A JSON number is read as a 64-bit float by the other SDKs, so we refuse every integer outside the safe integer range [-2^53 + 1, 2^53 - 1] consistently: for integer properties and in JSON-able values, in JSON and XML de/serialization, in XML-RPC, in parsing with ``int`` and in the verification. Once the verification passes, the serialization thus works. The bounds live in a single shared predicate per SDK, e.g., in Go: func IsSafeInteger(value int64) bool { return -9007199254740991 <= value && value <= 9007199254740991 } (``common.is_safe_integer`` in Python, ``common::IsSafeInteger`` in C++, ``Common.IsSafeInteger`` in C#, ``Verification.isSafeInteger`` in Java and ``Number.isSafeInteger`` in TypeScript), generated if ``intermediate_uses.safe_integer_checks`` holds. We accept a whole float such as ``5.0`` for an integer property. The new live-test data and the generated unit tests cover the bounds in all SDKs. The generated tests set an unsafe integer at every integer property, and check that the verification and both serializations refuse it. We hence made ``no_seven_from`` in ``int_length_interplay`` check ``start`` against the bounds before it indexes, as an invariant would otherwise crash on ``count = -(2 ** 53)``. The transpiled ``int(...)`` keeps checking the safe integer range, and we document why: TypeScript represents the integers as ``number``, so all SDKs check the same range to behave the same. --- aas_core_codegen/cpp/lib/_generate_common.py | 105 ++- .../lib/_generate_json_value_verification.py | 129 +++- .../cpp/lib/_generate_jsonization.py | 412 +++++++----- .../cpp/lib/_generate_verification.py | 331 ++++++++-- .../cpp/lib/_generate_xml_common.py | 4 +- aas_core_codegen/cpp/lib/_generate_xml_rpc.py | 25 +- .../cpp/lib/_generate_xmlization.py | 71 +- .../cpp/tests/_generate_common.py | 7 + .../_generate_test_json_value_verification.py | 59 ++ ...te_test_jsonization_of_concrete_classes.py | 373 +++++++++-- .../cpp/tests/_generate_test_verification.py | 69 +- .../cpp/tests/_generate_test_xml_rpc.py | 44 ++ ...ate_test_xmlization_of_concrete_classes.py | 83 ++- .../csharp/lib/_generate_common.py | 85 ++- .../lib/_generate_json_value_verification.py | 112 +++- .../csharp/lib/_generate_jsonization.py | 280 ++++++-- .../csharp/lib/_generate_verification.py | 90 ++- .../csharp/lib/_generate_xml_rpc.py | 44 +- .../csharp/lib/_generate_xmlization.py | 67 ++ aas_core_codegen/csharp/main.py | 16 + aas_core_codegen/csharp/tests/__init__.py | 2 + ...te_test_jsonization_of_concrete_classes.py | 99 +-- .../tests/_generate_test_safe_integers.py | 469 ++++++++++++++ .../csharp/tests/_generate_test_xml_rpc.py | 42 ++ .../golang/lib/_generate_common.py | 114 +++- .../golang/lib/_generate_jsonization.py | 179 +++--- .../golang/lib/_generate_verification.py | 92 ++- .../golang/lib/_generate_xml_common.py | 26 +- .../golang/lib/_generate_xml_rpc.py | 31 +- aas_core_codegen/golang/main.py | 27 +- aas_core_codegen/golang/tests/__init__.py | 6 + ..._jsonization_test_concrete_classes_test.py | 434 ++++++++++--- ...nerate_jsonization_test_json_value_test.py | 183 ++++++ .../tests/_generate_safe_integer_test.py | 107 +++ ...e_xmlization_test_concrete_classes_test.py | 83 ++- aas_core_codegen/intermediate/uses.py | 63 +- aas_core_codegen/java/lib/_generate_common.py | 32 +- .../java/lib/_generate_jsonization.py | 280 +++++--- .../java/lib/_generate_verification.py | 302 +++++++-- .../java/lib/_generate_xml_rpc.py | 33 +- .../java/lib/_generate_xmlization.py | 77 ++- ...te_test_jsonization_of_concrete_classes.py | 399 +++++++++++- ...ate_test_xmlization_of_concrete_classes.py | 47 ++ .../python/lib/_generate_common.py | 124 ++-- .../lib/_generate_json_value_verification.py | 9 +- .../python/lib/_generate_jsonization.py | 118 ++-- .../python/lib/_generate_verification.py | 44 +- .../python/lib/_generate_xml_rpc.py | 9 +- .../python/lib/_generate_xmlization.py | 40 +- aas_core_codegen/python/main.py | 17 + aas_core_codegen/python/tests/__init__.py | 2 + .../_generate_test_json_value_verification.py | 15 +- ...te_test_jsonization_of_concrete_classes.py | 216 +++++-- .../tests/_generate_test_safe_integers.py | 359 +++++++++++ .../python/tests/_generate_test_xml_rpc.py | 20 +- ...ate_test_xmlization_of_concrete_classes.py | 35 + .../typescript/lib/_generate_common.py | 68 +- .../typescript/lib/_generate_jsonization.py | 60 +- .../typescript/lib/_generate_verification.py | 87 ++- .../typescript/lib/_generate_xmlization.py | 40 +- ...erate_jsonization_concrete_classes_spec.py | 155 ++++- ...nerate_xmlization_concrete_classes_spec.py | 63 +- .../int_length_interplay.py | 15 +- .../expected_symbol_table.txt | 7 +- .../Something/int_above_the_safe_range.json | 7 + .../Something/int_below_the_safe_range.json | 7 + .../something/int_above_the_safe_range.xml | 1 + .../something/int_below_the_safe_range.xml | 1 + .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../test_xmlization_of_concrete_classes.cpp | 21 +- .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 158 ++--- .../expected_output/src/verification.cpp | 438 ++++++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 233 ++++++- .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 15 +- .../arithmetic/expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 123 +++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 423 +++++++++--- .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 461 ++++++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 274 ++++++-- .../test/test_verification.cpp | 50 ++ .../test_xmlization_of_concrete_classes.cpp | 101 +++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 331 +++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 169 ++++- .../test/test_verification.cpp | 50 ++ .../test_xmlization_of_concrete_classes.cpp | 59 +- .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 15 +- .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 93 ++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 169 ++++- .../test/test_verification.cpp | 50 ++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 41 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 158 ++--- .../expected_output/src/verification.cpp | 495 +++++++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 192 +++++- .../test/test_verification.cpp | 50 ++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../enum/expected_output/src/jsonization.cpp | 146 ----- .../enum/expected_output/src/verification.cpp | 4 +- .../enum/expected_output/src/xmlization.cpp | 123 ---- .../enum/expected_output/test/common.hpp | 7 + .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 400 +++++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 156 ++++- .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 158 ++--- .../expected_output/src/verification.cpp | 607 +++++++++++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 233 ++++++- .../test/test_verification.cpp | 50 ++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 41 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 158 ++--- .../expected_output/src/verification.cpp | 363 ++++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 151 ++++- .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 17 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 477 +++++++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 197 +++++- .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 46 +- .../expected_output/src/common.cpp | 25 +- .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../test/test_verification.cpp | 50 ++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/jsonization.hpp | 70 ++ .../json_types/expected_output/src/common.cpp | 20 + .../src/json_value_verification.cpp | 89 ++- .../src/json_value_verification.hpp | 18 + .../expected_output/src/jsonization.cpp | 329 ++++------ .../expected_output/src/verification.cpp | 2 +- .../expected_output/src/xml_rpc.cpp | 25 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../test/test_json_value_verification.cpp | 54 ++ .../test_jsonization_of_concrete_classes.cpp | 292 +++++++++ .../test/test_verification.cpp | 50 ++ .../expected_output/test/test_xml_rpc.cpp | 39 ++ .../expected_output/include/dummy/common.hpp | 41 ++ .../include/dummy/jsonization.hpp | 70 ++ .../expected_output/src/common.cpp | 20 + .../src/json_value_verification.cpp | 89 ++- .../src/json_value_verification.hpp | 18 + .../expected_output/src/jsonization.cpp | 327 ++++------ .../expected_output/src/verification.cpp | 2 +- .../expected_output/src/xml_rpc.cpp | 25 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../test/test_json_value_verification.cpp | 54 ++ .../test/test_verification.cpp | 50 ++ .../expected_output/test/test_xml_rpc.cpp | 39 ++ .../expected_output/include/dummy/common.hpp | 41 ++ .../include/dummy/jsonization.hpp | 70 ++ .../expected_output/src/common.cpp | 20 + .../src/json_value_verification.cpp | 89 ++- .../src/json_value_verification.hpp | 18 + .../expected_output/src/jsonization.cpp | 323 ++++------ .../expected_output/src/verification.cpp | 2 +- .../expected_output/src/xml_rpc.cpp | 25 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../test/test_json_value_verification.cpp | 54 ++ .../test_jsonization_of_concrete_classes.cpp | 244 +++++++ .../test/test_verification.cpp | 50 ++ .../expected_output/test/test_xml_rpc.cpp | 39 ++ .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 158 ++--- .../expected_output/src/verification.cpp | 567 +++++++++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 151 ++++- .../test/test_verification.cpp | 50 ++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 400 +++++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 182 +++++- .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 331 +++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 151 ++++- .../test/test_verification.cpp | 50 ++ .../test_xmlization_of_concrete_classes.cpp | 101 +++ .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/jsonization.hpp | 70 ++ .../expected_output/src/common.cpp | 20 + .../src/json_value_verification.cpp | 89 ++- .../src/json_value_verification.hpp | 18 + .../expected_output/src/jsonization.cpp | 317 ++++----- .../expected_output/src/verification.cpp | 2 +- .../expected_output/src/xml_rpc.cpp | 25 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../test/test_json_value_verification.cpp | 54 ++ .../test/test_verification.cpp | 50 ++ .../expected_output/test/test_xml_rpc.cpp | 39 ++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 15 +- .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 158 ++--- .../expected_output/src/verification.cpp | 144 ++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 151 ++++- .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 272 +++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 41 ++ .../include/dummy/jsonization.hpp | 70 ++ .../expected_output/src/common.cpp | 20 + .../src/json_value_verification.cpp | 89 ++- .../src/json_value_verification.hpp | 18 + .../expected_output/src/jsonization.cpp | 317 ++++----- .../expected_output/src/verification.cpp | 2 +- .../expected_output/src/xml_rpc.cpp | 25 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../test/test_json_value_verification.cpp | 54 ++ .../test/test_verification.cpp | 50 ++ .../expected_output/test/test_xml_rpc.cpp | 39 ++ .../expected_output/include/dummy/common.hpp | 41 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 243 ++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 169 ++++- .../test/test_verification.cpp | 50 ++ .../test_xmlization_of_concrete_classes.cpp | 59 +- .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../sets/expected_output/src/common.cpp | 20 + .../sets/expected_output/src/jsonization.cpp | 158 ++--- .../sets/expected_output/src/verification.cpp | 195 +++++- .../sets/expected_output/src/xmlization.cpp | 17 + .../sets/expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 197 +++++- .../test/test_verification.cpp | 50 ++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 157 ++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 151 ++++- .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 160 ++--- .../expected_output/src/verification.cpp | 339 +++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 192 +++++- .../test/test_verification.cpp | 50 ++ .../expected_output/include/dummy/common.hpp | 41 ++ .../include/dummy/verification.hpp | 15 +- .../tuples/expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 158 ++--- .../expected_output/src/verification.cpp | 269 +++++++- .../tuples/expected_output/src/xmlization.cpp | 17 + .../tuples/expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 237 ++++++- .../test/test_verification.cpp | 50 ++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../unions/expected_output/src/xmlization.cpp | 123 ---- .../unions/expected_output/test/common.hpp | 7 + .../expected_output/include/dummy/common.hpp | 37 ++ .../include/dummy/verification.hpp | 12 + .../expected_output/src/common.cpp | 20 + .../expected_output/src/jsonization.cpp | 158 ++--- .../expected_output/src/verification.cpp | 345 +++++++++- .../expected_output/src/xmlization.cpp | 17 + .../expected_output/test/common.hpp | 7 + .../test_jsonization_of_concrete_classes.cpp | 151 ++++- .../test/test_verification.cpp | 50 ++ .../expected_output/src/jsonization.cpp | 146 ----- .../expected_output/src/verification.cpp | 4 +- .../expected_output/src/xmlization.cpp | 123 ---- .../expected_output/test/common.hpp | 7 + .../AasCore.Aas3_0/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 324 ++++++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 53 +- .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 476 ++++++++++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 89 +++ .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 362 +++++++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 60 +- .../expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 37 ++ .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 35 + .../expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 286 +++++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 46 +- .../expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../enum/expected_output/dummy/jsonization.cs | 52 -- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 56 ++ .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 324 ++++++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 53 +- .../expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 286 +++++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 87 +++ .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 160 +++++ .../expected_output/dummy/common.cs | 63 +- .../expected_output/dummy/jsonization.cs | 52 -- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 407 ++++++++++++ .../expected_output/dummy.Tests/TestXmlRpc.cs | 38 ++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 204 +++--- .../dummy/jsonvalueverification.cs | 98 ++- .../expected_output/dummy/xmlrpc.cs | 43 +- .../dummy.Tests/TestSafeIntegers.cs | 215 +++++++ .../expected_output/dummy.Tests/TestXmlRpc.cs | 38 ++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 204 +++--- .../dummy/jsonvalueverification.cs | 98 ++- .../expected_output/dummy/xmlrpc.cs | 43 +- .../dummy.Tests/TestSafeIntegers.cs | 311 +++++++++ .../expected_output/dummy.Tests/TestXmlRpc.cs | 38 ++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 204 +++--- .../dummy/jsonvalueverification.cs | 98 ++- .../expected_output/dummy/xmlrpc.cs | 43 +- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 38 +- .../expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 56 ++ .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 37 ++ .../expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 97 ++- .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 160 +++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 42 ++ .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 38 +- .../expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 286 +++++++++ .../sets/expected_output/dummy/common.cs | 52 ++ .../sets/expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 92 +++ .../sets/expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 37 ++ .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 286 +++++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 44 ++ .../expected_output/dummy/xmlization.cs | 49 +- .../dummy.Tests/TestSafeIntegers.cs | 340 ++++++++++ .../tuples/expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 73 ++- .../expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../dummy.Tests/TestSafeIntegers.cs | 248 +++++++ .../expected_output/dummy/common.cs | 52 ++ .../expected_output/dummy/jsonization.cs | 113 +++- .../expected_output/dummy/verification.cs | 38 +- .../expected_output/dummy/xmlization.cs | 49 +- .../expected_output/dummy/jsonization.cs | 52 -- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../xmlization/test/concrete_classes_test.go | 20 +- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 193 +++++- .../verification/verification.go | 61 +- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 324 +++++++++- .../verification/verification.go | 130 ++++ .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 225 ++++++- .../verification/verification.go | 76 ++- .../xmlization/test/concrete_classes_test.go | 126 ++++ .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 132 +++- .../verification/verification.go | 31 + .../xmlization/test/concrete_classes_test.go | 82 ++- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 132 +++- .../verification/verification.go | 23 + .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 161 ++++- .../verification/verification.go | 47 +- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 129 +++- .../verification/verification.go | 56 ++ .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 193 +++++- .../verification/verification.go | 61 +- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 161 ++++- .../verification/verification.go | 99 +++ .../expected_output/common/common.go | 22 +- .../safe_integer_test/safe_integer_test.go | 36 ++ .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../expected_output/common/common.go | 39 ++ .../safe_integer_test/safe_integer_test.go | 36 ++ .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 203 +++--- .../jsonization/test/concrete_classes_test.go | 178 +++++ .../jsonization/test/json_value_test.go | 138 ++++ .../verification/verification.go | 33 +- .../expected_output/xmlrpc/xmlrpc.go | 29 +- .../expected_output/common/common.go | 39 ++ .../safe_integer_test/safe_integer_test.go | 36 ++ .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 192 ++---- .../jsonization/test/json_value_test.go | 138 ++++ .../verification/verification.go | 33 +- .../expected_output/xmlrpc/xmlrpc.go | 29 +- .../expected_output/common/common.go | 39 ++ .../safe_integer_test/safe_integer_test.go | 36 ++ .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 191 ++---- .../jsonization/test/json_value_test.go | 138 ++++ .../verification/verification.go | 33 +- .../expected_output/xmlrpc/xmlrpc.go | 29 +- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 129 +++- .../verification/verification.go | 31 +- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 132 +++- .../verification/verification.go | 56 ++ .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 129 +++- .../verification/verification.go | 31 + .../xmlization/test/concrete_classes_test.go | 126 ++++ .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 116 ++-- .../jsonization/test/concrete_classes_test.go | 129 +++- .../verification/verification.go | 118 +++- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../verification/verification.go | 33 + .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 132 +++- .../verification/verification.go | 32 + .../xmlization/test/concrete_classes_test.go | 82 ++- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../sets/expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 116 ++-- .../jsonization/test/concrete_classes_test.go | 161 ++++- .../verification/verification.go | 111 ++++ .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 129 +++- .../verification/verification.go | 31 + .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 161 ++++- .../verification/verification.go | 46 ++ .../tuples/expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 116 ++-- .../jsonization/test/concrete_classes_test.go | 197 +++++- .../verification/verification.go | 91 ++- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../expected_output/common/common.go | 31 + .../safe_integer_test/safe_integer_test.go | 59 ++ .../internal/xmlcommon/xmlcommon.go | 110 ++-- .../jsonization/jsonization.go | 117 ++-- .../jsonization/test/concrete_classes_test.go | 129 +++- .../verification/verification.go | 31 +- .../internal/xmlcommon/xmlcommon.go | 46 -- .../jsonization/jsonization.go | 84 --- .../aas3_0/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 92 ++- .../java/dummy/xmlization/Xmlization.java | 36 +- .../TestJsonizationOfConcreteClasses.java | 200 ++++++ .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 132 +++- .../java/dummy/xmlization/Xmlization.java | 46 +- .../TestJsonizationOfConcreteClasses.java | 344 ++++++++++ .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 101 ++- .../java/dummy/xmlization/Xmlization.java | 38 +- .../TestJsonizationOfConcreteClasses.java | 236 +++++++ .../java/TestXmlizationOfConcreteClasses.java | 66 ++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 74 +++ .../java/dummy/xmlization/Xmlization.java | 32 +- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/TestXmlizationOfConcreteClasses.java | 35 + .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 67 +- .../java/dummy/xmlization/Xmlization.java | 32 +- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 83 ++- .../java/dummy/xmlization/Xmlization.java | 34 +- .../TestJsonizationOfConcreteClasses.java | 164 +++++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 91 +++ .../java/dummy/xmlization/Xmlization.java | 58 +- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 92 ++- .../java/dummy/xmlization/Xmlization.java | 36 +- .../TestJsonizationOfConcreteClasses.java | 200 ++++++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 122 ++++ .../java/dummy/xmlization/Xmlization.java | 62 +- .../TestJsonizationOfConcreteClasses.java | 164 +++++ .../main/java/dummy/common/StringHelpers.java | 18 +- .../java/dummy/jsonization/Jsonization.java | 12 - .../java/dummy/verification/Verification.java | 44 ++ .../TestJsonizationOfConcreteClasses.java | 92 +++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 175 +++-- .../java/dummy/verification/Verification.java | 180 ++++-- .../src/main/java/dummy/xmlrpc/XmlRpc.java | 33 +- .../TestJsonizationOfConcreteClasses.java | 270 ++++++++ .../java/dummy/jsonization/Jsonization.java | 175 +++-- .../java/dummy/verification/Verification.java | 168 +++-- .../src/main/java/dummy/xmlrpc/XmlRpc.java | 33 +- .../TestJsonizationOfConcreteClasses.java | 139 ++++ .../java/dummy/jsonization/Jsonization.java | 175 +++-- .../java/dummy/verification/Verification.java | 168 +++-- .../src/main/java/dummy/xmlrpc/XmlRpc.java | 33 +- .../TestJsonizationOfConcreteClasses.java | 232 +++++++ .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 74 ++- .../java/dummy/xmlization/Xmlization.java | 32 +- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 91 +++ .../java/dummy/xmlization/Xmlization.java | 58 +- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 74 +++ .../java/dummy/xmlization/Xmlization.java | 32 +- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/TestXmlizationOfConcreteClasses.java | 66 ++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 141 +++- .../java/dummy/xmlization/Xmlization.java | 132 ++-- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 76 +++ .../java/dummy/xmlization/Xmlization.java | 32 +- .../TestJsonizationOfConcreteClasses.java | 92 +++ .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 74 ++- .../java/dummy/xmlization/Xmlization.java | 32 +- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/TestXmlizationOfConcreteClasses.java | 35 + .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 126 ++++ .../java/dummy/xmlization/Xmlization.java | 64 +- .../TestJsonizationOfConcreteClasses.java | 164 +++++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 74 +++ .../java/dummy/xmlization/Xmlization.java | 32 +- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 83 +++ .../java/dummy/xmlization/Xmlization.java | 34 +- .../TestJsonizationOfConcreteClasses.java | 164 +++++ .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 121 +++- .../java/dummy/xmlization/Xmlization.java | 74 ++- .../TestJsonizationOfConcreteClasses.java | 232 +++++++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../java/dummy/jsonization/Jsonization.java | 58 +- .../java/dummy/verification/Verification.java | 74 ++- .../java/dummy/xmlization/Xmlization.java | 32 +- .../TestJsonizationOfConcreteClasses.java | 128 ++++ .../java/dummy/jsonization/Jsonization.java | 12 - .../TestJsonizationOfConcreteClasses.java | 13 + .../test_jsonization_of_concrete_classes.py | 137 +++- .../dev/tests/test_safe_integers.py | 254 ++++++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 46 +- .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 325 +++++++--- .../dev/tests/test_safe_integers.py | 498 ++++++++++++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 75 +++ .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 182 ++++-- .../dev/tests/test_safe_integers.py | 315 +++++++++ .../test_xmlization_of_concrete_classes.py | 18 + .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 55 +- .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 55 +- .../dev/tests/test_safe_integers.py | 132 ++++ .../test_xmlization_of_concrete_classes.py | 18 + .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 18 + .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 55 +- .../dev/tests/test_safe_integers.py | 132 ++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 11 + .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 92 ++- .../dev/tests/test_safe_integers.py | 193 ++++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 37 +- .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 40 -- .../dev/tests/test_safe_integers.py | 132 ++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 33 + .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 137 +++- .../dev/tests/test_safe_integers.py | 254 ++++++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 46 +- .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 65 +- .../dev/tests/test_safe_integers.py | 193 ++++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 65 +- .../expected_output/dummy/xmlization.py | 21 +- .../dev/tests/test_safe_integers.py | 28 + .../expected_output/dummy/common.py | 52 +- .../dev/tests/test_json_value_verification.py | 15 +- .../dev/tests/test_safe_integers.py | 254 ++++++++ .../expected_output/dev/tests/test_xml_rpc.py | 20 +- .../expected_output/dummy/common.py | 58 +- .../expected_output/dummy/jsonization.py | 69 +- .../dummy/jsonvalueverification.py | 9 +- .../expected_output/dummy/xmlrpc.py | 9 +- .../dev/tests/test_json_value_verification.py | 15 +- .../dev/tests/test_safe_integers.py | 35 + .../expected_output/dev/tests/test_xml_rpc.py | 20 +- .../expected_output/dummy/common.py | 58 +- .../expected_output/dummy/jsonization.py | 63 +- .../dummy/jsonvalueverification.py | 9 +- .../expected_output/dummy/xmlrpc.py | 9 +- .../dev/tests/test_json_value_verification.py | 15 +- .../dev/tests/test_safe_integers.py | 193 ++++++ .../expected_output/dev/tests/test_xml_rpc.py | 20 +- .../expected_output/dummy/common.py | 58 +- .../expected_output/dummy/jsonization.py | 55 +- .../dummy/jsonvalueverification.py | 9 +- .../expected_output/dummy/xmlrpc.py | 9 +- .../test_jsonization_of_concrete_classes.py | 47 +- .../dev/tests/test_safe_integers.py | 132 ++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 23 +- .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 18 - .../dev/tests/test_safe_integers.py | 132 ++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 33 + .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 47 +- .../dev/tests/test_safe_integers.py | 132 ++++ .../test_xmlization_of_concrete_classes.py | 18 + .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 18 + .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 47 +- .../dev/tests/test_safe_integers.py | 132 ++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 73 ++- .../expected_output/dummy/xmlization.py | 21 +- .../dev/tests/test_safe_integers.py | 35 + .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 19 + .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 55 +- .../dev/tests/test_safe_integers.py | 132 ++++ .../test_xmlization_of_concrete_classes.py | 18 + .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 23 +- .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 65 +- .../dev/tests/test_safe_integers.py | 193 ++++++ .../sets/expected_output/dummy/common.py | 47 ++ .../sets/expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 67 ++ .../sets/expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 47 +- .../dev/tests/test_safe_integers.py | 132 ++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 18 + .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 92 ++- .../dev/tests/test_safe_integers.py | 193 ++++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 27 + .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 83 ++- .../dev/tests/test_safe_integers.py | 254 ++++++++ .../tuples/expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 57 +- .../expected_output/dummy/xmlization.py | 21 +- .../test_jsonization_of_concrete_classes.py | 47 +- .../dev/tests/test_safe_integers.py | 132 ++++ .../expected_output/dummy/common.py | 47 ++ .../expected_output/dummy/jsonization.py | 45 +- .../expected_output/dummy/verification.py | 23 +- .../expected_output/dummy/xmlization.py | 21 +- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../test/xmlization.concreteClasses.spec.ts | 15 +- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 48 +- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 189 ++++-- .../arithmetic/expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 93 ++- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 401 +++++++++--- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 58 +- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 242 +++++-- .../test/xmlization.concreteClasses.spec.ts | 66 ++ .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../comparisons/expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 26 + .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../test/xmlization.concreteClasses.spec.ts | 32 +- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 33 +- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 38 +- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 136 +++- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../enum/expected_output/src/jsonization.ts | 30 - .../enum/expected_output/src/xmlization.ts | 51 -- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 45 ++ .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 48 +- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 189 ++++-- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 83 ++- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 136 +++- .../int_parsing/expected_output/src/common.ts | 15 +- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/jsonization.ts | 44 +- .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/jsonization.ts | 44 +- .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/jsonization.ts | 44 +- .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 27 +- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 48 +- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 26 + .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../test/xmlization.concreteClasses.spec.ts | 66 ++ .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 90 ++- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 28 + .../expected_output/src/xmlization.ts | 29 +- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 27 +- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../test/xmlization.concreteClasses.spec.ts | 32 +- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../sets/expected_output/src/common.ts | 31 + .../sets/expected_output/src/jsonization.ts | 40 +- .../sets/expected_output/src/verification.ts | 85 +++ .../sets/expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 136 +++- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 26 + .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 36 ++ .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 136 +++- .../tuples/expected_output/src/common.ts | 31 + .../tuples/expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 69 +- .../tuples/expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 189 ++++-- .../unions/expected_output/src/jsonization.ts | 30 - .../unions/expected_output/src/xmlization.ts | 51 -- .../expected_output/src/common.ts | 31 + .../expected_output/src/jsonization.ts | 40 +- .../expected_output/src/verification.ts | 27 +- .../expected_output/src/xmlization.ts | 29 +- .../test/jsonization.concreteClasses.spec.ts | 83 ++- .../expected_output/src/jsonization.ts | 30 - .../expected_output/src/xmlization.ts | 51 -- .../expected_symbol_table.txt | 48 ++ 1146 files changed, 68502 insertions(+), 22189 deletions(-) create mode 100644 aas_core_codegen/csharp/tests/_generate_test_safe_integers.py create mode 100644 aas_core_codegen/golang/tests/_generate_jsonization_test_json_value_test.py create mode 100644 aas_core_codegen/golang/tests/_generate_safe_integer_test.py create mode 100644 aas_core_codegen/python/tests/_generate_test_safe_integers.py create mode 100644 dev/test_data/live_tests/common_test_data/primitive_types/Json/Unexpected/Unserializable/ValueViolation/Something/int_above_the_safe_range.json create mode 100644 dev/test_data/live_tests/common_test_data/primitive_types/Json/Unexpected/Unserializable/ValueViolation/Something/int_below_the_safe_range.json create mode 100644 dev/test_data/live_tests/common_test_data/primitive_types/Xml/Unexpected/Unserializable/ValueViolation/something/int_above_the_safe_range.xml create mode 100644 dev/test_data/live_tests/common_test_data/primitive_types/Xml/Unexpected/Unserializable/ValueViolation/something/int_below_the_safe_range.xml create mode 100644 dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/comparisons/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/for_statements/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/if_statements/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/json_types/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/method_references/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/sets/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/tuples/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy.Tests/TestSafeIntegers.cs create mode 100644 dev/test_data/main/golang/expected/annotated_assignments/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/arithmetic/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/assert_statements/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/comparisons/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/constrained_primitives/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/for_statements/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/if_statements/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/int_length_interplay/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/int_parsing/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/json_types/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/json_types/expected_output/jsonization/test/json_value_test.go create mode 100644 dev/test_data/main/golang/expected/json_types_in_collections/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/json_types_in_collections/expected_output/jsonization/test/json_value_test.go create mode 100644 dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/jsonization/test/json_value_test.go create mode 100644 dev/test_data/main/golang/expected/list_of_classes/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/list_of_primitives/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/method_references/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/nested_collections/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/optional_arguments/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/primitive_types/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/sets/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/switch_statements/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/transpiled_methods/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/tuples/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/golang/expected/variance_of_containers/expected_output/common/safe_integer_test/safe_integer_test.go create mode 100644 dev/test_data/main/python/expected/annotated_assignments/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/arithmetic/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/constrained_primitives/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/for_statements/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/if_statements/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/int_length_interplay/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/int_parsing/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/list_of_classes/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/list_of_primitives/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/nested_collections/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/optional_arguments/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/sets/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/switch_statements/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/transpiled_methods/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/tuples/expected_output/dev/tests/test_safe_integers.py create mode 100644 dev/test_data/main/python/expected/variance_of_containers/expected_output/dev/tests/test_safe_integers.py diff --git a/aas_core_codegen/cpp/lib/_generate_common.py b/aas_core_codegen/cpp/lib/_generate_common.py index 576eeb2f7..8cbad2db8 100644 --- a/aas_core_codegen/cpp/lib/_generate_common.py +++ b/aas_core_codegen/cpp/lib/_generate_common.py @@ -834,6 +834,85 @@ def _generate_string_helper_definitions( # the methods of the types can use it. It is public so that the clients can rely # on it, and so that we can unit-test it. + +#: Declarations of the predicates checking the safe integer range +IS_SAFE_INTEGER_DECLARATIONS: Final[Sequence[Stripped]] = [ + Stripped( + """\ +/** + * \\brief Check that the integer \\p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \\param value to be checked + * \\return true if \\p value lies in the range + */ +bool IsSafeInteger(int64_t value);""" + ), + Stripped( + """\ +/** + * \\brief Check that the unsigned integer \\p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \\param value to be checked + * \\return true if \\p value lies in the range + */ +bool IsSafeInteger(uint64_t value);""" + ), + Stripped( + """\ +/** + * \\brief Check that the whole number \\p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \\p value is whole. + * + * \\param value to be checked + * \\return true if \\p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value);""" + ), +] + +#: Definitions of the predicates checking the safe integer range +IS_SAFE_INTEGER_DEFINITIONS: Final[Sequence[Stripped]] = [ + Stripped( + f"""\ +bool IsSafeInteger(int64_t value) {{ +{I}return ( +{II}-9007199254740991LL <= value +{II}&& value <= 9007199254740991LL +{I}); +}}""" + ), + Stripped( + f"""\ +bool IsSafeInteger(uint64_t value) {{ +{I}return value <= 9007199254740991ULL; +}}""" + ), + Stripped( + f"""\ +bool IsSafeInteger(double value) {{ +{I}// NOTE (mristin): +{I}// A not-a-number fails both comparisons, so it lies outside the range. +{I}return ( +{II}-9007199254740991.0 <= value +{II}&& value <= 9007199254740991.0 +{I}); +}}""" + ), +] + + #: Name of the helper function to parse the safe integers PARSE_SAFE_INT_NAME: Final[Identifier] = Identifier("ParseSafeInt") @@ -847,8 +926,13 @@ def _generate_string_helper_definitions( * transpilation. We accept only an optional sign followed by the ASCII digits, * and only the safe integers, i.e., the integers within * -(2^53 - 1) and 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 an int64_t could hold + * larger integers. TypeScript represents the integers as 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. * * \\param text to be parsed * \\return parsed integer @@ -862,9 +946,6 @@ def _generate_string_helper_definitions( PARSE_SAFE_INT_DEFINITION = Stripped( f"""\ int64_t ParseSafeInt(const std::wstring& text) {{ -{I}// NOTE: This is 2^53 - 1. -{I}const int64_t kMaxSafeInteger = 9007199254740991LL; - {I}size_t offset = 0; {I}bool negative = false; {I}if (!text.empty() && (text[0] == L'-' || text[0] == L'+')) {{ @@ -892,7 +973,7 @@ def _generate_string_helper_definitions( {II}// NOTE: The value never overflows as we check it after each digit, and {II}// ten times the largest safe integer fits into int64_t. {II}value = value * 10 + static_cast(character - L'0'); -{II}if (value > kMaxSafeInteger) {{ +{II}if (!IsSafeInteger(value)) {{ {III}throw std::invalid_argument( {IIII}"Expected a safe integer, but got a text out of its range: " {IIII}+ WstringToUtf8(text) @@ -1346,7 +1427,7 @@ def generate_header( or intermediate_uses.string_slicing(type_inference) or intermediate_uses.string_find_call(type_inference) or intermediate_uses.modulo(symbol_table) - or intermediate_uses.int_call(symbol_table) + or intermediate_uses.safe_integer_checks(symbol_table) ): extra_std_includes.append("#include ") @@ -1648,6 +1729,11 @@ def generate_header( if intermediate_uses.modulo(symbol_table) else [] ), + *( + IS_SAFE_INTEGER_DECLARATIONS + if intermediate_uses.safe_integer_checks(symbol_table) + else [] + ), *( [PARSE_SAFE_INT_DECLARATION] if intermediate_uses.int_call(symbol_table) @@ -1956,6 +2042,11 @@ def generate_implementation( else [] ), *([FLOOR_MOD_DEFINITION] if intermediate_uses.modulo(symbol_table) else []), + *( + IS_SAFE_INTEGER_DEFINITIONS + if intermediate_uses.safe_integer_checks(symbol_table) + else [] + ), *( [PARSE_SAFE_INT_DEFINITION] if intermediate_uses.int_call(symbol_table) diff --git a/aas_core_codegen/cpp/lib/_generate_json_value_verification.py b/aas_core_codegen/cpp/lib/_generate_json_value_verification.py index 12f142be9..7a47887b5 100644 --- a/aas_core_codegen/cpp/lib/_generate_json_value_verification.py +++ b/aas_core_codegen/cpp/lib/_generate_json_value_verification.py @@ -2,7 +2,8 @@ # NOTE (mristin): # This module is private (it is emitted to ``src/``, never installed alongside -# the public headers in ``include/``), included only by ``verification.cpp``. +# the public headers in ``include/``), included by ``verification.cpp``, and by +# ``jsonization.cpp`` for the shared ``CheckJsonAbleNumber``. # It provides ``JsonValueVerificator``, a hand-written ``impl::IVerificator`` # (see ``verification.hpp``) which exhaustively walks a ``nlohmann::json`` # value with an explicit, runtime stack -- unlike every other verificator in @@ -29,6 +30,115 @@ ) +def _generate_check_json_able_number_declaration() -> Stripped: + """Generate the declaration of the check of a number in a JSON-able value.""" + return Stripped( + """\ +/** + * \\brief Check that the number \\p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * 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 json number to be checked + * \\return the cause why \\p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +);""" + ) + + +def _generate_check_json_able_number_implementation() -> 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. + """ + return Stripped( + f"""\ +namespace {{ +/** + * \\brief Describe that the \\p integer of a JSON-able value lies outside + * the safe integer range. + * + * \\param integer textual representation of the integer + * \\return the cause of the error + */ +std::wstring DescribeJsonAbleIntegerOutsideSafeRange( +{I}const std::wstring& integer +) {{ +{I}return common::Concat( +{II}L"Expected a JSON-able value, but got the integer ", +{II}integer, +{II}L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" +{I}); +}} +}} // namespace + +/** + * \\brief Check that the number \\p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * \\param json number to be checked + * \\return the cause why \\p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( +{I}const nlohmann::json& json +) {{ +{I}switch (json.type()) {{ +{II}case nlohmann::json::value_t::number_integer: {{ +{III}const int64_t integer = json.get(); +{III}if (!common::IsSafeInteger(integer)) {{ +{IIII}return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); +{III}}} +{III}return common::nullopt; +{II}}} + +{II}case nlohmann::json::value_t::number_unsigned: {{ +{III}const uint64_t integer = json.get(); +{III}if (!common::IsSafeInteger(integer)) {{ +{IIII}return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); +{III}}} +{III}return common::nullopt; +{II}}} + +{II}case nlohmann::json::value_t::number_float: {{ +{III}// NOTE (mristin): +{III}// JSON knows neither an infinity nor a not-a-number, so we refuse both +{III}// instead of silently writing them out as ``null``. The caller gives us +{III}// an ``nlohmann::json`` which may well have been constructed +{III}// programmatically, so a non-finite number can reach us even though no +{III}// conformant parser would ever produce one. +{III}const double number = json.get(); +{III}if (!std::isfinite(number)) {{ +{IIII}return common::Concat( +{IIIII}L"Expected a JSON-able value, but got the number ", +{IIIII}std::to_wstring(number), +{IIIII}L", which is neither finite nor representable in JSON" +{IIII}); +{III}}} +{III}return common::nullopt; +{II}}} + +{II}default: +{III}return common::nullopt; +{I}}} +}}""" + ) + + def _generate_json_value_verificator_declaration() -> List[Stripped]: """Generate the declaration of ``JsonValueVerificator``.""" return [ @@ -175,6 +285,7 @@ class JsonValueVerificator : public impl::IVerificator {{ def _generate_json_value_verificator_implementation() -> List[Stripped]: """Generate the implementation of ``JsonValueVerificator``.""" return [ + _generate_check_json_able_number_implementation(), Stripped( f"""\ namespace {{ @@ -198,19 +309,8 @@ def _generate_json_value_verificator_implementation() -> List[Stripped]: {II}); {I}}} -{I}// NOTE (mristin): -{I}// JSON knows neither an infinity nor a not-a-number, so neither is -{I}// a JSON-able value, even though ``nlohmann::json`` will happily hold -{I}// one if it was constructed programmatically. This mirrors what the -{I}// jsonization refuses to serialize. -{I}if (value.is_number_float() && !std::isfinite(value.get())) {{ -{II}return common::make_optional( -{III}common::Concat( -{IIII}L"Expected a JSON-able value, but got the number ", -{IIII}std::to_wstring(value.get()), -{IIII}L", which is neither finite nor representable in JSON" -{III}) -{II}); +{I}if (value.is_number()) {{ +{II}return CheckJsonAbleNumber(value); {I}}} {I}return common::nullopt; @@ -491,6 +591,7 @@ def generate_header(library_namespace: Stripped) -> str: ), cpp_common.generate_namespace_opening(library_namespace), Stripped(f"namespace {cpp_common.VERIFICATION_NAMESPACE} {{"), + _generate_check_json_able_number_declaration(), *_generate_json_value_verificator_declaration(), Stripped(f"}} // namespace {cpp_common.VERIFICATION_NAMESPACE}"), cpp_common.generate_namespace_closing(library_namespace), diff --git a/aas_core_codegen/cpp/lib/_generate_jsonization.py b/aas_core_codegen/cpp/lib/_generate_jsonization.py index 2eddc0fc1..e000b34ca 100644 --- a/aas_core_codegen/cpp/lib/_generate_jsonization.py +++ b/aas_core_codegen/cpp/lib/_generate_jsonization.py @@ -95,6 +95,69 @@ def _generate_deserialization_definitions( return blocks +def _generate_json_able_from_definitions( + symbol_table: intermediate.SymbolTable, +) -> List[Stripped]: + """Generate the declarations of the public JSON-able de-serialization.""" + if not intermediate_uses.json_types(symbol_table): + return [] + + blocks: List[Stripped] = [] + for name in ("JsonValue", "JsonArray", "JsonObject"): + what = name[len("Json") :].lower() + blocks.append( + Stripped( + f"""\ +/** + * \\brief Deserialize \\p json as a JSON-able {what}. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \\param json value to be de-serialized + * \\return a copy of \\p json, or a de-serialization error, if any. + */ +common::expected< +{I}nlohmann::json, +{I}DeserializationError +> {name}From( +{I}const nlohmann::json& json +);""" + ) + ) + + return blocks + + +def _generate_serialize_json_value_definitions( + symbol_table: intermediate.SymbolTable, +) -> List[Stripped]: + """Generate the declaration of the public JSON-able serialization.""" + if not intermediate_uses.json_types(symbol_table): + return [] + + return [ + Stripped( + f"""\ +/** + * \\brief Serialize the JSON-able value \\p value to a JSON value. + * + * \\param value JSON-able value to be serialized + * \\return a copy of \\p value + * \\throw \\ref SerializationException if \\p value contains, at any depth, + * a binary or a discarded value, an infinity, a not-a-number or an integer + * outside the safe integer range [-2^53 + 1, 2^53 - 1] + */ +nlohmann::json SerializeJsonValue( +{I}const nlohmann::json& value +);""" + ) + ] + + # fmt: off @ensure( lambda result: @@ -265,6 +328,7 @@ class ISegment {{ }}; // struct DeserializationError""" ), *_generate_deserialization_definitions(symbol_table=symbol_table), + *_generate_json_able_from_definitions(symbol_table=symbol_table), Stripped("// endregion Deserialization"), Stripped("// region Serialization"), Stripped( @@ -306,6 +370,7 @@ class SerializationException : public std::exception {{ {I}const types::IClass& that );""" ), + *_generate_serialize_json_value_definitions(symbol_table=symbol_table), Stripped("// endregion Serialization"), Stripped( f"""\ @@ -683,7 +748,12 @@ def _generate_deserialize_bool() -> Stripped: def _generate_deserialize_int() -> Stripped: - """Generate the function to de-serialize an int from a JSON value.""" + """ + Generate the function to de-serialize an int from a JSON value. + + We accept a whole floating-point number such as ``5.0``, but refuse any integer + outside the safe integer range, as the other readers might quietly round it. + """ return Stripped( f"""\ std::pair< @@ -692,102 +762,85 @@ def _generate_deserialize_int() -> Stripped: > DeserializeInt64( {I}const nlohmann::json& json ) {{ -{I}if (!json.is_number()) {{ -{II}std::wstring message = common::Concat( -{III}L"Expected an integer number, but got a value of type: ", -{III}common::Utf8ToWstring(json.type_name()) -{II}); - -{II}return std::make_pair< -{III}common::optional, -{III}common::optional -{II}>( -{III}common::nullopt, -{III}common::make_optional( -{IIII}message -{III}) -{II}); -{I}}} - {I}static_assert( {II}std::is_same::value, {II}"Expected nlohmann::json::number_integer_t to equal int64_t, " {II}"but it does not." {I}); -{I}if (json.is_number_integer()) {{ -{II}return std::make_pair< -{III}common::optional, -{III}common::optional -{II}>( -{III}json.get(), -{III}common::nullopt -{II}); -{I}}} - -{I}if (json.is_number_unsigned()) {{ -{II}std::wstring message = common::Concat( -{III}L"Expected a 64-bit integer number, " -{III}L"but got an unsigned integer number which does not fit in that range: ", -{III}std::to_wstring(json.get()) -{II}); - -{II}return std::make_pair< -{III}common::optional, -{III}common::optional -{II}>( -{III}common::nullopt, -{III}common::make_optional( -{IIII}message -{III}) -{II}); -{I}}} - -{I}// NOTE (mristin): -{I}// We have to check that the number is an integer even though it can -{I}// not be stored in int64_t in order to give an informative message. +{I}int64_t value = 0; +{I}common::optional cause; + +{I}switch (json.type()) {{ +{II}case nlohmann::json::value_t::number_integer: {{ +{III}const int64_t integer = json.get(); +{III}if (common::IsSafeInteger(integer)) {{ +{IIII}value = integer; +{III}}} else {{ +{IIII}cause = verification::impl::DescribeIntegerOutsideSafeRange( +{IIIII}std::to_wstring(integer) +{IIII}); +{III}}} +{III}break; +{II}}} -{I}const nlohmann::json::number_float_t number( -{II}json.get() -{I}); +{II}case nlohmann::json::value_t::number_unsigned: {{ +{III}const uint64_t integer = json.get(); +{III}if (common::IsSafeInteger(integer)) {{ +{IIII}value = static_cast(integer); +{III}}} else {{ +{IIII}cause = verification::impl::DescribeIntegerOutsideSafeRange( +{IIIII}std::to_wstring(integer) +{IIII}); +{III}}} +{III}break; +{II}}} -{I}nlohmann::json::number_float_t integer_part; -{I}const bool is_integer( -{II}std::modf(number, &integer_part) == 0 -{I}); -{I}if (is_integer) {{ -{II}std::wstring message = common::Concat( -{III}L"Expected a 64-bit integer number, " -{III}L"but got an integer number which does not fit in that range: ", -{III}std::to_wstring(number) -{II}); +{II}case nlohmann::json::value_t::number_float: {{ +{III}const double number = json.get(); +{III}if (!std::isfinite(number) || std::trunc(number) != number) {{ +{IIII}cause = common::Concat( +{IIIII}L"Expected an integer, but got ", +{IIIII}std::to_wstring(number), +{IIIII}L", which is not a whole number" +{IIII}); +{III}}} else if (common::IsSafeInteger(number)) {{ +{IIII}value = static_cast(number); +{III}}} else {{ +{IIII}cause = verification::impl::DescribeIntegerOutsideSafeRange( +{IIIII}std::to_wstring(number) +{IIII}); +{III}}} +{III}break; +{II}}} -{II}return std::make_pair< -{III}common::optional, -{III}common::optional -{II}>( -{III}common::nullopt, -{III}common::make_optional( -{IIII}message -{III}) -{II}); -{I}}} else {{ -{II}std::wstring message = common::Concat( -{III}L"Expected a 64-bit integer number, " -{III}L"but got a non-integer number: ", -{III}std::to_wstring(number) -{II}); +{II}default: +{III}cause = common::Concat( +{IIII}L"Expected an integer, but got: ", +{IIII}common::Utf8ToWstring(json.type_name()) +{III}); +{III}break; +{I}}} +{I}if (cause.has_value()) {{ {II}return std::make_pair< {III}common::optional, {III}common::optional {II}>( {III}common::nullopt, {III}common::make_optional( -{IIII}message +{IIII}std::move(*cause) {III}) {II}); {I}}} + +{I}return std::make_pair< +{II}common::optional, +{II}common::optional +{I}>( +{II}value, +{II}common::nullopt +{I}); }}""" ) @@ -995,17 +1048,77 @@ def _json_serialize_function_for( ) -> Stripped: """Determine the function to serialize the given JSON-able ``type_anno``.""" if isinstance(type_anno, intermediate.JsonValueTypeAnnotation): - return Stripped("SerializeJsonValue") + return Stripped("TrySerializeJsonValue") elif isinstance(type_anno, intermediate.JsonArrayTypeAnnotation): - return Stripped("SerializeJsonArray") + return Stripped("TrySerializeJsonArray") elif isinstance(type_anno, intermediate.JsonObjectTypeAnnotation): - return Stripped("SerializeJsonObject") + return Stripped("TrySerializeJsonObject") else: raise AssertionError( f"Expected a JSON-able type annotation, but got: {type_anno}" ) +def _generate_json_able_from(name: str) -> Stripped: + """ + Generate the public de-serialization of the JSON-able ``name``. + + The ``name`` is ``JsonValue``, ``JsonArray`` or ``JsonObject``. + """ + return Stripped( + f"""\ +common::expected< +{I}nlohmann::json, +{I}DeserializationError +> {name}From( +{I}const nlohmann::json& json +) {{ +{I}common::optional value; +{I}common::optional error; + +{I}std::tie( +{II}value, +{II}error +{I}) = Deserialize{name}(json); + +{I}if (error.has_value()) {{ +{II}return common::make_unexpected( +{III}std::move(*error) +{II}); +{I}}} + +{I}return std::move(*value); +}}""" + ) + + +def _generate_serialize_json_value_public() -> Stripped: + """Generate the public serialization of a JSON-able value.""" + return Stripped( + f"""\ +nlohmann::json SerializeJsonValue( +{I}const nlohmann::json& value +) {{ +{I}common::optional result; +{I}common::optional error; + +{I}std::tie( +{II}result, +{II}error +{I}) = TrySerializeJsonValue(value); + +{I}if (error.has_value()) {{ +{II}throw SerializationException( +{III}std::move(error->cause), +{III}std::move(error->path) +{II}); +{I}}} + +{I}return std::move(*result); +}}""" + ) + + def _generate_deserialize_json_value() -> Stripped: """ Generate the function to recursively de-serialize a JSON-able value. @@ -1089,27 +1202,19 @@ def _generate_deserialize_json_value() -> Stripped: {II}return std::make_pair(std::move(result), common::nullopt); {I}}} -{I}// NOTE (mristin): -{I}// JSON knows neither an infinity nor a not-a-number, so we refuse both -{I}// instead of silently writing them out as ``null``, mirroring -{I}// ``DeserializeDouble`` for the floating-point properties of the model. -{I}// The caller gives us an ``nlohmann::json`` which may well have been -{I}// constructed programmatically, so a non-finite number can reach us -{I}// even though no conformant parser would ever produce one. -{I}if (json.is_number_float() && !std::isfinite(json.get())) {{ -{II}return std::make_pair< -{III}common::optional, -{III}common::optional -{II}>( -{III}common::nullopt, -{III}common::make_optional( -{IIII}common::Concat( -{IIIII}L"Expected a JSON-able value, but got the number ", -{IIIII}std::to_wstring(json.get()), -{IIIII}L", which is neither finite nor representable in JSON" +{I}if (json.is_number()) {{ +{II}common::optional cause = verification::CheckJsonAbleNumber(json); +{II}if (cause.has_value()) {{ +{III}return std::make_pair< +{IIII}common::optional, +{IIII}common::optional +{III}>( +{IIII}common::nullopt, +{IIII}common::make_optional( +{IIIII}std::move(*cause) {IIII}) -{III}) -{II}); +{III}); +{II}}} {I}}} {I}// NOTE (mristin): @@ -1195,7 +1300,7 @@ def _generate_serialize_json_value() -> Stripped: std::pair< {I}common::optional, {I}common::optional -> SerializeJsonValue( +> TrySerializeJsonValue( {I}const nlohmann::json& value ) {{ {I}if (value.is_binary() || value.is_discarded()) {{ @@ -1219,7 +1324,7 @@ def _generate_serialize_json_value() -> Stripped: {II}for (const nlohmann::json& item : value) {{ {III}common::optional serialized_item; {III}common::optional error; -{III}std::tie(serialized_item, error) = SerializeJsonValue(item); +{III}std::tie(serialized_item, error) = TrySerializeJsonValue(item); {III}if (error.has_value()) {{ {IIII}error->path.segments.emplace_front( @@ -1242,7 +1347,7 @@ def _generate_serialize_json_value() -> Stripped: {II}for (const auto& item : value.items()) {{ {III}common::optional serialized_value; {III}common::optional error; -{III}std::tie(serialized_value, error) = SerializeJsonValue(item.value()); +{III}std::tie(serialized_value, error) = TrySerializeJsonValue(item.value()); {III}if (error.has_value()) {{ {IIII}error->path.segments.emplace_front( @@ -1260,27 +1365,19 @@ def _generate_serialize_json_value() -> Stripped: {II}return std::make_pair(std::move(result), common::nullopt); {I}}} -{I}// NOTE (mristin): -{I}// JSON knows neither an infinity nor a not-a-number, so we refuse both -{I}// instead of silently writing them out as ``null``, mirroring -{I}// ``SerializeDouble`` for the floating-point properties of the model. -{I}// The caller gives us an ``nlohmann::json`` which may well have been -{I}// constructed programmatically, so a non-finite number can reach us -{I}// even though no conformant parser would ever produce one. -{I}if (value.is_number_float() && !std::isfinite(value.get())) {{ -{II}return std::make_pair< -{III}common::optional, -{III}common::optional -{II}>( -{III}common::nullopt, -{III}common::make_optional( -{IIII}common::Concat( -{IIIII}L"Expected a JSON-able value, but got the number ", -{IIIII}std::to_wstring(value.get()), -{IIIII}L", which is neither finite nor representable in JSON" +{I}if (value.is_number()) {{ +{II}common::optional cause = verification::CheckJsonAbleNumber(value); +{II}if (cause.has_value()) {{ +{III}return std::make_pair< +{IIII}common::optional, +{IIII}common::optional +{III}>( +{IIII}common::nullopt, +{IIII}common::make_optional( +{IIIII}std::move(*cause) {IIII}) -{III}) -{II}); +{III}); +{II}}} {I}}} {I}// NOTE (mristin): @@ -1298,7 +1395,7 @@ def _generate_serialize_json_array() -> Stripped: std::pair< {I}common::optional, {I}common::optional -> SerializeJsonArray( +> TrySerializeJsonArray( {I}const nlohmann::json& value ) {{ {I}if (!value.is_array()) {{ @@ -1313,7 +1410,7 @@ def _generate_serialize_json_array() -> Stripped: {II}); {I}}} -{I}return SerializeJsonValue(value); +{I}return TrySerializeJsonValue(value); }}""" ) @@ -1325,7 +1422,7 @@ def _generate_serialize_json_object() -> Stripped: std::pair< {I}common::optional, {I}common::optional -> SerializeJsonObject( +> TrySerializeJsonObject( {I}const nlohmann::json& value ) {{ {I}if (!value.is_object()) {{ @@ -1340,7 +1437,7 @@ def _generate_serialize_json_object() -> Stripped: {II}); {I}}} -{I}return SerializeJsonValue(value); +{I}return TrySerializeJsonValue(value); }}""" ) @@ -3941,32 +4038,24 @@ def _generate_serialize_int() -> Stripped: /** * \\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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, for interoperability with other + * de-serializers. */ std::pair< {I}common::optional, {I}common::optional > SerializeInt64(int64_t value) {{ -{I}if ( -{II}value < -9007199254740991L -{II}|| value > 9007199254740991L -{I}) {{ -{II}const std::wstring message = common::Concat( -{III}L"The integer ", -{III}std::to_wstring(value), -{III}L" can not be serialized to JSON " -{III}L"as it is outside the range [-2^53 + 1, 2^53 - 1] and can not " -{III}L"be exactly represented as a 64-bit floating point number." -{II}); - +{I}if (!common::IsSafeInteger(value)) {{ {II}return std::make_pair< {III}common::optional, {III}common::optional {II}>( {III}common::nullopt, {III}common::make_optional( -{IIII}message +{IIII}verification::impl::DescribeIntegerOutsideSafeRange( +{IIIII}std::to_wstring(value) +{IIII}) {III}) {II}); {I}}} @@ -5784,13 +5873,30 @@ def generate_implementation( include_prefix_path = cpp_common.generate_include_prefix_path(library_namespace) + # NOTE (mristin): + # The number check of a JSON-able value is shared with the verification, so + # that the two agree on what a JSON-able number is. + json_value_verification_include = ( + '\n#include "json_value_verification.hpp"' + if intermediate_uses.json_types(symbol_table) + else "" + ) + + # NOTE (mristin): + # The integers are checked against the safe integer range with the very same + # predicate as in the verification, so that the two agree. + uses_integers = intermediate_uses.integers(symbol_table) + verification_include = ( + f'\n#include "{include_prefix_path}/verification.hpp"' if uses_integers else "" + ) + blocks = [ cpp_common.WARNING, Stripped( f"""\ #include "{include_prefix_path}/jsonization.hpp" -#include "{include_prefix_path}/stringification.hpp" -#include "{include_prefix_path}/wstringification.hpp" +#include "{include_prefix_path}/stringification.hpp"{verification_include} +#include "{include_prefix_path}/wstringification.hpp"{json_value_verification_include} #pragma warning(push, 0) #include @@ -5808,7 +5914,7 @@ def generate_implementation( Stripped("// region De-serialization"), *_generate_deserialization_error_implementation(), _generate_deserialize_bool(), - _generate_deserialize_int(), + *([_generate_deserialize_int()] if uses_integers else []), _generate_deserialize_float(), _generate_deserialize_str(), _generate_deserialize_bytearray(), @@ -5917,6 +6023,15 @@ def generate_implementation( blocks.append(_generate_deserialize_from()) + if intermediate_uses.json_types(symbol_table): + blocks.extend( + [ + _generate_json_able_from("JsonValue"), + _generate_json_able_from("JsonArray"), + _generate_json_able_from("JsonObject"), + ] + ) + for cls in symbol_table.classes: blocks.append(_generate_deserialization_implementation(cls=cls)) @@ -5960,7 +6075,7 @@ def generate_implementation( ), *_generate_serialization_exception_implementation(), _generate_serialize_bool(), - _generate_serialize_int(), + *([_generate_serialize_int()] if uses_integers else []), _generate_serialize_double(), _generate_serialize_str(), _generate_serialize_bytearray(), @@ -6092,6 +6207,9 @@ def generate_implementation( blocks.append(_generate_serialize_implementation(fallible=iclass_is_fallible)) + if intermediate_uses.json_types(symbol_table): + blocks.append(_generate_serialize_json_value_public()) + blocks.extend( [ Stripped("// endregion Serialization"), diff --git a/aas_core_codegen/cpp/lib/_generate_verification.py b/aas_core_codegen/cpp/lib/_generate_verification.py index 28dcd6fa2..91e759d9a 100644 --- a/aas_core_codegen/cpp/lib/_generate_verification.py +++ b/aas_core_codegen/cpp/lib/_generate_verification.py @@ -250,7 +250,18 @@ def _generate_definition_of_verify_constrained_primitive( arg_name = cpp_naming.argument_name(Identifier("that")) - if cpp_common.primitive_type_is_referencable(constrained_primitive.constrainee): + if constrained_primitive.constrainee is intermediate.PrimitiveType.INT: + documentation_comment = Stripped( + """\ +/** + * \\brief Verify that the invariants hold for \\p that value, and that + * it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. + * + * \\param that value to be verified + * \\return Iterable over constraint violations + */""" + ) + elif cpp_common.primitive_type_is_referencable(constrained_primitive.constrainee): documentation_comment = Stripped( """\ /** @@ -532,6 +543,35 @@ def _generate_implementation_of_transpilable_verification( ) +def _generate_safe_integer_declarations( + symbol_table: intermediate.SymbolTable, +) -> List[Stripped]: + """ + Generate the declaration of the message on the integers outside the safe range. + + The range itself is checked by ``common::IsSafeInteger``. + """ + if not intermediate_uses.integers(symbol_table): + return [] + + return [ + Stripped( + """\ +/// \\cond HIDDEN +namespace impl { +/** + * \\brief Describe that the \\p integer lies outside the safe integer range. + * + * \\param integer textual representation of the integer + * \\return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \\endcond""" + ) + ] + + # fmt: off @ensure(lambda result: (result[0] is not None) ^ (result[1] is not None)) @ensure( @@ -742,6 +782,7 @@ class IVerificator {{ }} // namespace impl /// \\endcond""" ), + *_generate_safe_integer_declarations(symbol_table), Stripped( f"""\ class IVerification {{ @@ -960,6 +1001,34 @@ def _generate_error_implementation() -> List[Stripped]: ] +def _generate_safe_integer_implementations( + symbol_table: intermediate.SymbolTable, +) -> List[Stripped]: + """ + Generate the message on the integers outside the safe integer range. + + The range itself is checked by ``common::IsSafeInteger``, so that each bound + and each message is written exactly once. + """ + if not intermediate_uses.integers(symbol_table): + return [] + + return [ + Stripped( + f"""\ +namespace impl {{ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) {{ +{I}return common::Concat( +{II}L"Expected an integer, but got ", +{II}integer, +{II}L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" +{I}); +}} +}} // namespace impl""" + ) + ] + + def _generate_iterator_implementation() -> List[Stripped]: """Generate the implementation of the class ``Iterator``.""" return [ @@ -1248,6 +1317,64 @@ def _transpile_invariant( ] +#: Define the nested verification which reports a single error. +_SINGLE_ERROR_VERIFICATOR: Final[Sequence[Stripped]] = [ + Stripped( + f"""\ +/** + * \\brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \\ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator {{ + public: +{I}explicit SingleErrorVerificator( +{II}std::wstring cause +{I}) : +{II}cause_(std::move(cause)) {{ +{II}// Intentionally empty. +{I}}} + +{I}void Start() override {{ +{II}// NOTE (mristin): +{II}// We copy the cause as the caller might take over the data members of +{II}// the error, and we still need to be able to start again. +{II}error_ = Error(cause_); +{I}}} + +{I}void Next() override {{ +{II}error_ = common::nullopt; +{I}}} + +{I}bool Done() const override {{ +{II}return !error_.has_value(); +{I}}} + +{I}const Error& Get() const override {{ +{II}return *error_; +{I}}} + +{I}Error& GetMutable() override {{ +{II}return *error_; +{I}}} + +{I}long Index() const override {{ +{II}return error_.has_value() ? 0 : -1; +{I}}} + +{I}std::unique_ptr Clone() const override {{ +{II}return common::make_unique(*this); +{I}}} + + private: +{I}std::wstring cause_; +{I}common::optional error_; +}}; // class SingleErrorVerificator""" + ), +] + + #: Define the interface of the lazy iterators over the values to verify. _IITERATOR: Final[Sequence[Stripped]] = [ Stripped( @@ -2520,7 +2647,9 @@ def yields( the non-recursive verification. """ if isinstance(type_annotation, intermediate.PrimitiveTypeAnnotation): - return False + # NOTE (mristin): + # The integers have to lie in the safe integer range. + return type_annotation.a_type is intermediate.PrimitiveType.INT elif isinstance(type_annotation, intermediate.OurTypeAnnotation): our_type = type_annotation.our_type @@ -2529,7 +2658,10 @@ def yields( return False elif isinstance(our_type, intermediate.ConstrainedPrimitive): - return id(our_type) in self.checked_constrained_primitive_id_set + return ( + id(our_type) in self.checked_constrained_primitive_id_set + or our_type.constrainee is intermediate.PrimitiveType.INT + ) elif isinstance(our_type, intermediate.Class): concrete_classes = list(our_type.concrete_descendants) @@ -2598,6 +2730,12 @@ def yields_recursively( ) +#: Literal of ``Shape`` for the integers, which have to lie in the safe integer +#: range. Like the literals of JSON-able values, it never clashes with the literals +#: of our types by construction. +_INT64_SHAPE_LITERAL = Identifier("kINT64") + + def _shape_literal( our_type: Union[intermediate.ConstrainedPrimitive, intermediate.ConcreteClass] ) -> Identifier: @@ -2605,6 +2743,26 @@ def _shape_literal( return cpp_naming.enum_literal_name(our_type.name) +@require( + lambda constrained_primitive: len(constrained_primitive.invariants) > 0 + or constrained_primitive.constrainee is intermediate.PrimitiveType.INT, + "The values of the constrained primitive have something to verify", +) +def _constrained_primitive_shape_literal( + constrained_primitive: intermediate.ConstrainedPrimitive, +) -> Identifier: + """ + Generate the literal of ``Shape`` of the values of ``constrained_primitive``. + + A constrained primitive over integers without invariants only needs the check + of the safe integer range, so its values take the shape of the integers. + """ + if len(constrained_primitive.invariants) == 0: + return _INT64_SHAPE_LITERAL + + return _shape_literal(constrained_primitive) + + def _json_shape_literal( type_annotation: Union[ intermediate.JsonValueTypeAnnotation, @@ -2718,7 +2876,15 @@ def _generate_over_expression( ) elif isinstance(type_annotation, intermediate.PrimitiveTypeAnnotation): - raise AssertionError("Primitive values have nothing to verify") + assert ( + type_annotation.a_type is intermediate.PrimitiveType.INT + ), "Only the integers among the primitive values have anything to verify" + + shape = f"Shape::{_INT64_SHAPE_LITERAL}" + if by_value: + return cpp_over.generate_call("OneByValue", [expr, shape]) + + return cpp_over.generate_call("One", [f"&{expr}", shape]) elif isinstance(type_annotation, intermediate.OurTypeAnnotation): our_type = type_annotation.our_type @@ -2727,7 +2893,7 @@ def _generate_over_expression( raise AssertionError("Enumeration literals have nothing to verify") elif isinstance(our_type, intermediate.ConstrainedPrimitive): - shape = f"Shape::{_shape_literal(our_type)}" + shape = f"Shape::{_constrained_primitive_shape_literal(our_type)}" if by_value: return cpp_over.generate_call("OneByValue", [expr, shape]) @@ -3058,16 +3224,10 @@ def _generate_over_class( # NOTE (mristin): # The getters return booleans, integers and floating-point numbers by value, # unless they are optional. - constrained_primitive = ( - intermediate.try_constrained_primitive(prop.type_annotation) - if isinstance(prop.type_annotation, intermediate.OurTypeAnnotation) - else None - ) + primitive_type = intermediate.try_primitive_type(prop.type_annotation) by_value = ( - constrained_primitive is not None - and not cpp_common.primitive_type_is_referencable( - constrained_primitive.constrainee - ) + primitive_type is not None + and not cpp_common.primitive_type_is_referencable(primitive_type) ) expression = _generate_over_expression( @@ -3183,9 +3343,14 @@ def _generate_over_instance( def _generate_shape_enum( - symbol_table: intermediate.SymbolTable, uses_json: bool + symbol_table: intermediate.SymbolTable, nested_literals: Sequence[Identifier] ) -> Stripped: - """Generate the enumeration of the shapes of the values that we verify.""" + """ + Generate the enumeration of the shapes of the values that we verify. + + The ``nested_literals`` are the shapes of the built-in values which only + the nested verification verifies. + """ literals = [ _shape_literal(constrained_primitive) for constrained_primitive in symbol_table.constrained_primitives @@ -3196,8 +3361,7 @@ def _generate_shape_enum( for cls in symbol_table.concrete_classes if len(cls.invariants) > 0 ) - if uses_json: - literals.extend(_JSON_SHAPE_LITERALS) + literals.extend(nested_literals) literals_joined = ",\n".join( f"{literal} = {i}" for i, literal in enumerate(literals) @@ -3312,23 +3476,30 @@ def _generate_checks( return (functions, case_block), None -def _generate_checks_of(case_blocks: Sequence[Stripped], uses_json: bool) -> Stripped: - """Generate the function which gives out the checks of a shape.""" +def _generate_checks_of( + case_blocks: Sequence[Stripped], nested_literals: Sequence[Identifier] +) -> Stripped: + """ + Generate the function which gives out the checks of a shape. + + The values of the ``nested_literals`` are verified only by the nested + verification, see :py:func:`_generate_new_nested_verificator`. + """ blocks = list(case_blocks) no_checks = "" - if uses_json: + if len(nested_literals) > 0: no_checks = f"{I}static const std::vector kNoChecks;\n\n" - json_cases = "\n".join( - f"case Shape::{literal}:" for literal in _JSON_SHAPE_LITERALS + nested_cases = "\n".join( + f"case Shape::{literal}:" for literal in nested_literals ) blocks.append( Stripped( f"""\ -{json_cases} +{nested_cases} {I}// NOTE (mristin): -{I}// The JSON-able values are verified by the nested verification. +{I}// These values are verified by the nested verification. {I}return kNoChecks;""" ) ) @@ -3362,7 +3533,9 @@ def _generate_checks_of(case_blocks: Sequence[Stripped], uses_json: bool) -> Str ) -def _generate_new_nested_verificator(uses_json: bool) -> Stripped: +def _generate_new_nested_verificator( + symbol_table: intermediate.SymbolTable, uses_integers: bool, uses_json: bool +) -> Stripped: """Generate the factory of the nested verifications of the values.""" doc_comment = Stripped( """\ @@ -3373,7 +3546,7 @@ def _generate_new_nested_verificator(uses_json: bool) -> Stripped: */""" ) - if not uses_json: + if not uses_integers and not uses_json: return Stripped( f"""\ {doc_comment} @@ -3382,26 +3555,61 @@ def _generate_new_nested_verificator(uses_json: bool) -> Stripped: {I}const void* ) {{ {I}// NOTE (mristin): -{I}// The meta-model uses no JSON-able values, so no value needs a nested -{I}// verification. +{I}// The meta-model uses neither integers nor JSON-able values, so no value +{I}// needs a nested verification. {I}return nullptr; }}""" ) case_blocks: List[Stripped] = [] - for literal, json_value_shape in zip( - _JSON_SHAPE_LITERALS, ("kAny", "kArray", "kObject"), strict=True - ): + + if uses_integers: + # NOTE (mristin): + # The values of a constrained primitive over integers with invariants keep + # its own shape, so that its invariants are checked, but they also need + # the check of the safe integer range. + int64_literals = [ + _shape_literal(constrained_primitive) + for constrained_primitive in symbol_table.constrained_primitives + if ( + len(constrained_primitive.invariants) > 0 + and constrained_primitive.constrainee is intermediate.PrimitiveType.INT + ) + ] + int64_literals.append(_INT64_SHAPE_LITERAL) + + int64_cases = "\n".join(f"case Shape::{literal}:" for literal in int64_literals) + case_blocks.append( Stripped( f"""\ +{int64_cases} {{ +{I}const int64_t integer = *static_cast(value); +{I}if (common::IsSafeInteger(integer)) {{ +{II}return nullptr; +{I}}} + +{I}return common::make_unique( +{II}impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) +{I}); +}}""" + ) + ) + + if uses_json: + for literal, json_value_shape in zip( + _JSON_SHAPE_LITERALS, ("kAny", "kArray", "kObject"), strict=True + ): + case_blocks.append( + Stripped( + f"""\ case Shape::{literal}: {I}return common::make_unique( {II}*static_cast(value), {II}JsonValueShape::{json_value_shape} {I});""" + ) ) - ) case_blocks.append( Stripped( @@ -3439,7 +3647,10 @@ def _generate_implementation_of_verify_constrained_primitive( primitive_type=constrained_primitive.constrainee ) - if len(constrained_primitive.invariants) == 0: + if ( + len(constrained_primitive.invariants) == 0 + and constrained_primitive.constrainee is not intermediate.PrimitiveType.INT + ): return Stripped( f"""\ std::unique_ptr {verify_name}( @@ -3451,7 +3662,7 @@ def _generate_implementation_of_verify_constrained_primitive( }}""" ) - shape = f"Shape::{_shape_literal(constrained_primitive)}" + shape = f"Shape::{_constrained_primitive_shape_literal(constrained_primitive)}" values = ( cpp_over.generate_call("One", ["&that", shape]) @@ -3554,6 +3765,15 @@ def generate_implementation( errors: List[Error] = [] uses_json = intermediate_uses.json_types(symbol_table) + uses_integers = intermediate_uses.integers(symbol_table) + + # NOTE (mristin): + # The values of these shapes are verified only by the nested verification. + nested_literals: List[Identifier] = [] + if uses_integers: + nested_literals.append(_INT64_SHAPE_LITERAL) + if uses_json: + nested_literals.extend(_JSON_SHAPE_LITERALS) json_value_verification_include = ( '#include "json_value_verification.hpp"\n\n' if uses_json else "" @@ -3594,6 +3814,7 @@ def generate_implementation( ), cpp_common.generate_namespace_opening(namespace), *_generate_error_implementation(), + *_generate_safe_integer_implementations(symbol_table), ] # NOTE (mristin): @@ -3718,20 +3939,19 @@ def generate_implementation( *symbol_table.concrete_classes, ] - if uses_json: - taken_literal_set = { - _shape_literal(our_type) for our_type in constrained_primitives_and_classes - } - for literal in _JSON_SHAPE_LITERALS: - if literal in taken_literal_set: - errors.append( - Error( - None, - f"The shape literal {literal!r} of JSON-able values clashes " - f"with a class or a constrained primitive of the same name. " - f"Please rename it, or contact the developers.", - ) + taken_literal_set = { + _shape_literal(our_type) for our_type in constrained_primitives_and_classes + } + for literal in nested_literals: + if literal in taken_literal_set: + errors.append( + Error( + None, + f"The shape literal {literal!r} of built-in values clashes " + f"with a class or a constrained primitive of the same name. " + f"Please rename it, or contact the developers.", ) + ) check_functions: List[Stripped] = [] checks_of_case_blocks: List[Stripped] = [] @@ -3841,13 +4061,22 @@ def generate_implementation( blocks.extend( [ Stripped("namespace {"), - _generate_shape_enum(symbol_table=symbol_table, uses_json=uses_json), + _generate_shape_enum( + symbol_table=symbol_table, nested_literals=nested_literals + ), *_CHECK_STRUCT, Stripped("// region Checks"), *check_functions, - _generate_checks_of(case_blocks=checks_of_case_blocks, uses_json=uses_json), + _generate_checks_of( + case_blocks=checks_of_case_blocks, nested_literals=nested_literals + ), Stripped("// endregion Checks"), - _generate_new_nested_verificator(uses_json=uses_json), + *(_SINGLE_ERROR_VERIFICATOR if uses_integers else ()), + _generate_new_nested_verificator( + symbol_table=symbol_table, + uses_integers=uses_integers, + uses_json=uses_json, + ), Stripped("// region Iteration over the values"), *_IITERATOR, *combinators, diff --git a/aas_core_codegen/cpp/lib/_generate_xml_common.py b/aas_core_codegen/cpp/lib/_generate_xml_common.py index 0af16208a..bde815d81 100644 --- a/aas_core_codegen/cpp/lib/_generate_xml_common.py +++ b/aas_core_codegen/cpp/lib/_generate_xml_common.py @@ -1731,8 +1731,8 @@ def _generate_self_closing_writer_declaration( ) # NOTE (mristin): - # See https://github.com/aas-core-works/aas-core-meta/issues/298 on why we - # do not check the range of ``SerializeInt64``. + # ``SerializeInt64`` writes any integer. The xmlization checks the safe integer + # range of the integers of the model before it writes them. return [ Stripped("// region class SelfClosingWriter"), Stripped( diff --git a/aas_core_codegen/cpp/lib/_generate_xml_rpc.py b/aas_core_codegen/cpp/lib/_generate_xml_rpc.py index 244a3b8bf..57bf454b0 100644 --- a/aas_core_codegen/cpp/lib/_generate_xml_rpc.py +++ b/aas_core_codegen/cpp/lib/_generate_xml_rpc.py @@ -1342,23 +1342,21 @@ def _generate_serialize_value_body_to_implementation() -> Stripped: {II}writer.SerializeString(value.get() ? "1" : "0"); {II}writer.StopElement("boolean"); {I}}} else if (value.is_number()) {{ -{II}const double number = value.get(); - {II}// NOTE (mristin): -{II}// JSON knows neither an infinity nor a not-a-number, so neither is -{II}// a JSON-able value. ``SelfClosingWriter::SerializeDouble`` would -{II}// happily write "INF" or "NaN" -- the xs:double spellings -- which +{II}// We check the number exactly as the verification and the JSON +{II}// de/serialization do. In particular, JSON knows neither an infinity nor +{II}// a not-a-number, and ``SelfClosingWriter::SerializeDouble`` would happily +{II}// write "INF" or "NaN" -- the xs:double spellings -- which {II}// ``DeserializeDoubleBodyFrom`` deliberately refuses to read back. -{II}if (!std::isfinite(number)) {{ -{III}return common::make_optional( -{IIII}common::Concat( -{IIIII}L"Expected a JSON-able value, but got the number ", -{IIIII}std::to_wstring(number), -{IIIII}L", which is neither finite nor representable in JSON" -{IIII}) -{III}); +{II}common::optional cause( +{III}verification::CheckJsonAbleNumber(value) +{II}); +{II}if (cause.has_value()) {{ +{III}return common::make_optional(std::move(*cause)); {II}}} +{II}const double number = value.get(); + {II}writer.StartElementInNoNamespace( {III}"double", undeclare_namespace {II}); @@ -1566,6 +1564,7 @@ def generate_implementation(library_namespace: Stripped) -> str: Stripped( """\ #include "xml_rpc.hpp" +#include "json_value_verification.hpp" #include "xml_common.hpp" #pragma warning(push, 0) diff --git a/aas_core_codegen/cpp/lib/_generate_xmlization.py b/aas_core_codegen/cpp/lib/_generate_xmlization.py index 8200823aa..40062439f 100644 --- a/aas_core_codegen/cpp/lib/_generate_xmlization.py +++ b/aas_core_codegen/cpp/lib/_generate_xmlization.py @@ -1642,8 +1642,15 @@ def _generate_named_union_from_element( ) -def _generate_functions_to_deserialize_primitives() -> List[Stripped]: - """Generate functions to parse text nodes to primitives.""" +def _generate_functions_to_deserialize_primitives( + uses_integers: bool, +) -> List[Stripped]: + """ + Generate functions to parse text nodes to primitives. + + The integers are parsed only if ``uses_integers``, *i.e.*, if the meta-model + holds any. + """ return [ Stripped("// region De-serialize primitives"), Stripped( @@ -1717,8 +1724,10 @@ def _generate_functions_to_deserialize_primitives() -> List[Stripped]: {I}); }}""" ), - Stripped( - f"""\ + *( + [ + Stripped( + f"""\ std::pair< {I}common::optional, {I}common::optional @@ -1816,6 +1825,14 @@ def _generate_functions_to_deserialize_primitives() -> List[Stripped]: {II}); {I}}} +{I}if (!common::IsSafeInteger(*deserialized)) {{ +{II}return NoInstanceAndDeserializationErrorWithCause( +{III}verification::impl::DescribeIntegerOutsideSafeRange( +{IIII}std::to_wstring(*deserialized) +{III}) +{II}); +{I}}} + {I}// NOTE (mristin): {I}// We consume the text node. {I}reader.Read(); @@ -1829,6 +1846,10 @@ def _generate_functions_to_deserialize_primitives() -> List[Stripped]: {II}common::nullopt {I}); }}""" + ), + ] + if uses_integers + else [] ), Stripped( f"""\ @@ -3910,8 +3931,13 @@ def _generate_serialization_exception_implementation() -> List[Stripped]: ] -def _generate_write_primitives() -> List[Stripped]: - """Generate the writers of the primitive values as the content of an element.""" +def _generate_write_primitives(uses_integers: bool) -> List[Stripped]: + """ + Generate the writers of the primitive values as the content of an element. + + The integers are written only if ``uses_integers``, *i.e.*, if the meta-model + holds any. + """ return [ Stripped( f"""\ @@ -3927,12 +3953,22 @@ def _generate_write_primitives() -> List[Stripped]: {I}return common::nullopt; }}""" ), - Stripped( - f"""\ + *( + [ + Stripped( + f"""\ common::optional WriteInt64( {I}int64_t value, {I}xml_common::SelfClosingWriter& writer ) {{ +{I}if (!common::IsSafeInteger(value)) {{ +{II}return xml_common::SerializationError( +{III}verification::impl::DescribeIntegerOutsideSafeRange( +{IIII}std::to_wstring(value) +{III}) +{II}); +{I}}} + {I}writer.SerializeInt64(value); {I}if (writer.error().has_value()) {{ {II}return writer.move_error(); @@ -3940,6 +3976,10 @@ def _generate_write_primitives() -> List[Stripped]: {I}return common::nullopt; }}""" + ), + ] + if uses_integers + else [] ), Stripped( f"""\ @@ -5635,6 +5675,14 @@ def generate_implementation( include_prefix_path = cpp_common.generate_include_prefix_path(library_namespace) + # NOTE (mristin): + # The integers are checked against the safe integer range with the very same + # predicate as in the verification, so that the two agree. + uses_integers = intermediate_uses.integers(symbol_table) + verification_include = ( + f'#include "{include_prefix_path}/verification.hpp"\n' if uses_integers else "" + ) + xml_rpc_include = ( '#include "xml_rpc.hpp"\n' if intermediate_uses.json_types(symbol_table) else "" ) @@ -5644,6 +5692,7 @@ def generate_implementation( Stripped( f"""\ #include "{include_prefix_path}/stringification.hpp" +{verification_include}\ #include "{include_prefix_path}/wstringification.hpp" #include "{include_prefix_path}/xmlization.hpp" @@ -5721,7 +5770,9 @@ def generate_implementation( # our own dispatch function to construct the right alternative. blocks.append(_generate_named_union_from_element(named_union=named_union)) - blocks.extend(_generate_functions_to_deserialize_primitives()) + blocks.extend( + _generate_functions_to_deserialize_primitives(uses_integers=uses_integers) + ) if intermediate_uses.json_types(symbol_table): # NOTE (mristin): @@ -5819,7 +5870,7 @@ def generate_implementation( Stripped("// endregion De-serialization"), Stripped("// region Serialization"), *_generate_serialization_exception_implementation(), - *_generate_write_primitives(), + *_generate_write_primitives(uses_integers=uses_integers), ] ) diff --git a/aas_core_codegen/cpp/tests/_generate_common.py b/aas_core_codegen/cpp/tests/_generate_common.py index 47e8aca2f..2ae725715 100644 --- a/aas_core_codegen/cpp/tests/_generate_common.py +++ b/aas_core_codegen/cpp/tests/_generate_common.py @@ -158,6 +158,13 @@ def generate_header(library_namespace: Stripped) -> str: std::string JoinStrings(const T& container, const std::string& delimiter) {{ {I}static_assert(std::is_same::value); +{I}// NOTE (mristin): +{I}// We have to handle the empty container explicitly, as the computations +{I}// below subtract one from its size, which would wrap around. +{I}if (container.empty()) {{ +{II}return ""; +{I}}} + {I}size_t size = 0; {I}for (const std::string& part : container {II}) {{ diff --git a/aas_core_codegen/cpp/tests/_generate_test_json_value_verification.py b/aas_core_codegen/cpp/tests/_generate_test_json_value_verification.py index a0f5e01d1..51c542636 100644 --- a/aas_core_codegen/cpp/tests/_generate_test_json_value_verification.py +++ b/aas_core_codegen/cpp/tests/_generate_test_json_value_verification.py @@ -10,6 +10,7 @@ INDENT as I, INDENT2 as II, INDENT3 as III, + INDENT4 as IIII, ) @@ -36,6 +37,7 @@ def generate_implementation(library_namespace: Stripped) -> str: ), Stripped( f"""\ +#include "{include_prefix_path}/jsonization.hpp" #include "{include_prefix_path}/verification.hpp" #include "json_value_verification.hpp" @@ -44,6 +46,7 @@ def generate_implementation(library_namespace: Stripped) -> str: #include #include +#include #include #pragma warning(pop)""" ), @@ -299,6 +302,62 @@ def generate_implementation(library_namespace: Stripped) -> str: {I}const std::vector errors(CollectErrors(verificator)); {I}REQUIRE(errors.size() == 1); +}}""" + ), + Stripped( + f"""\ +TEST_CASE("Reject an integer just outside the safe integer range") {{ +{I}// NOTE (mristin): +{I}// We construct the values programmatically, so that we cover both +{I}// the signed and the unsigned integers of ``nlohmann::json``. +{I}const std::vector > cases = {{ +{II}{{nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}}, +{II}{{nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}}, +{II}{{nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"}} +{I}}}; + +{I}for (const std::pair& a_case : cases) {{ +{II}const nlohmann::json& value = a_case.first; + +{II}verification::JsonValueVerificator verificator( +{III}value, verification::JsonValueShape::kAny +{II}); + +{II}const std::vector errors(CollectErrors(verificator)); +{II}REQUIRE(errors.size() == 1); +{II}REQUIRE( +{III}errors[0].cause +{III}== L"Expected a JSON-able value, but got the integer " + a_case.second +{IIII}+ L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" +{II}); + +{II}REQUIRE(!our::jsonization::JsonValueFrom(value).has_value()); + +{II}REQUIRE_THROWS_AS( +{III}our::jsonization::SerializeJsonValue(value), +{III}our::jsonization::SerializationException +{II}); +{I}}} +}}""" + ), + Stripped( + f"""\ +TEST_CASE("Accept the integers at the bounds of the safe integer range") {{ +{I}for ( +{II}const int64_t integer : {{9007199254740991LL, -9007199254740991LL}} +{I}) {{ +{II}const nlohmann::json value(integer); + +{II}verification::JsonValueVerificator verificator( +{III}value, verification::JsonValueShape::kAny +{II}); + +{II}REQUIRE(CollectErrors(verificator).empty()); + +{II}REQUIRE(our::jsonization::JsonValueFrom(value).has_value()); + +{II}REQUIRE(our::jsonization::SerializeJsonValue(value) == value); +{I}}} }}""" ), cpp_common.WARNING, diff --git a/aas_core_codegen/cpp/tests/_generate_test_jsonization_of_concrete_classes.py b/aas_core_codegen/cpp/tests/_generate_test_jsonization_of_concrete_classes.py index 58049d798..968e1017b 100644 --- a/aas_core_codegen/cpp/tests/_generate_test_jsonization_of_concrete_classes.py +++ b/aas_core_codegen/cpp/tests/_generate_test_jsonization_of_concrete_classes.py @@ -1,9 +1,9 @@ """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 +from icontract import ensure, require from aas_core_codegen import intermediate, naming from aas_core_codegen.common import ( @@ -113,31 +113,141 @@ def _generate_serialization_failure_infrastructure() -> List[Stripped]: ] -def _generate_serialization_failure_test_case( - numeric_place: intermediate.NumericPlace, -) -> Stripped: - """Generate the test that a number unrepresentable in JSON is refused.""" - if numeric_place.a_type is intermediate.PrimitiveType.FLOAT: - value_type = "double" - zero_literal = "0.0" - what = "a non-finite floating-point number" - value_literals = [ - "std::numeric_limits::infinity()", - "-std::numeric_limits::infinity()", - "std::numeric_limits::quiet_NaN()", - ] - else: - value_type = "int64_t" - zero_literal = "0LL" - what = "an integer outside the range representable in JSON" - value_literals = ["9007199254740992LL", "-9007199254740992LL"] +def _generate_safe_integer_infrastructure() -> List[Stripped]: + """Generate the helpers for the tests of the safe integer range.""" + return [ + Stripped( + f"""\ +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \\p that instance all report \\p expected_cause at \\p expected_path. + */ +void AssertReportedEverywhere( +{I}const std::shared_ptr& that, +{I}const std::string& expected_path, +{I}const std::wstring& expected_cause +) {{ +{I}bool reported = false; +{I}std::vector error_messages; +{I}for ( +{II}const our::verification::Error& error +{II}: our::verification::RecursiveVerification(that) +{I}) {{ +{II}const std::string path( +{III}our::common::WstringToUtf8(error.path.ToWstring()) +{II}); +{II}if (path == expected_path && error.cause == expected_cause) {{ +{III}reported = true; +{III}break; +{II}}} + +{II}error_messages.emplace_back( +{III}our::common::Concat( +{IIII}path, +{IIII}": ", +{IIII}our::common::WstringToUtf8(error.cause) +{III}) +{II}); +{I}}} + +{I}INFO( +{II}our::common::Concat( +{III}"Expected the verification to report at ", +{III}expected_path, +{III}": ", +{III}our::common::WstringToUtf8(expected_cause), +{III}", but got:\\n", +{III}test::common::JoinStrings(error_messages, "\\n") +{II}) +{I}) +{I}REQUIRE(reported); +{I}try {{ +{II}our::jsonization::Serialize(*that); + +{II}INFO("Expected the JSON serialization to fail at " + expected_path) +{II}REQUIRE(false); +{I}}} catch (const our::jsonization::SerializationException& exception) {{ +{II}REQUIRE( +{III}our::common::WstringToUtf8(exception.path().ToWstring()) +{IIII}== expected_path +{II}); +{II}REQUIRE(exception.cause() == expected_cause); +{I}}} + +{I}std::ostringstream oss; +{I}try {{ +{II}our::xmlization::Serialize(*that, {{}}, oss); + +{II}INFO("Expected the XML serialization to fail at " + expected_path) +{II}REQUIRE(false); +{I}}} catch (const our::xmlization::SerializationException& exception) {{ +{II}REQUIRE( +{III}our::common::WstringToUtf8(exception.path().ToWstring()) +{IIII}== expected_path +{II}); +{II}REQUIRE(exception.cause() == expected_cause); +{I}}} +}}""" + ), + Stripped( + f"""\ +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \\p that instance report an error of the safe integer range + * at \\p path. + */ +void AssertNotReportedAnywhere( +{I}const std::shared_ptr& that, +{I}const std::string& path +) {{ +{I}for ( +{II}const our::verification::Error& error +{II}: our::verification::RecursiveVerification(that) +{I}) {{ +{II}INFO( +{III}our::common::Concat( +{IIII}our::common::WstringToUtf8(error.path.ToWstring()), +{IIII}": ", +{IIII}our::common::WstringToUtf8(error.cause) +{III}) +{II}) +{II}REQUIRE( +{III}!( +{IIII}our::common::WstringToUtf8(error.path.ToWstring()) == path +{IIII}&& error.cause.find(L"safe integer range") != std::wstring::npos +{III}) +{II}); +{I}}} + +{I}REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + +{I}std::ostringstream oss; +{I}REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {{}}, oss)); +}}""" + ), + ] + + +def _generate_mutation( + numeric_place: intermediate.NumericPlace, value_type: str, zero_literal: str +) -> Tuple[Stripped, str]: + """ + Generate the code which sets ``value`` at the ``numeric_place`` of ``instance``. + + Return the code, and the path to the value. + """ setter_name = cpp_naming.setter_name(numeric_place.prop.name) if numeric_place.index is None: - mutation = Stripped(f"instance->{setter_name}(value);") - expected_path = f".{numeric_place.prop.name}" - elif numeric_place.in_list: + return ( + Stripped(f"instance->{setter_name}(value);"), + f".{numeric_place.prop.name}", + ) + + expected_path = f".{numeric_place.prop.name}[{numeric_place.index}]" + + 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. @@ -146,25 +256,39 @@ def _generate_serialization_failure_test_case( for i in range(numeric_place.index + 1) ) - mutation = Stripped( - f"""\ + return ( + Stripped( + f"""\ instance->{setter_name}( {I}std::vector<{value_type}>{{ {II}{indent_but_first_line(items_joined, II)} {I}}} );""" + ), + expected_path, ) - expected_path = f".{numeric_place.prop.name}[{numeric_place.index}]" - else: - mutable_getter_name = cpp_naming.mutable_getter_name(numeric_place.prop.name) - mutation = Stripped( + mutable_getter_name = cpp_naming.mutable_getter_name(numeric_place.prop.name) + + return ( + Stripped( f"""\ std::get<{numeric_place.index}>( {I}instance->{mutable_getter_name}() ) = value;""" - ) - expected_path = f".{numeric_place.prop.name}[{numeric_place.index}]" + ), + expected_path, + ) + + +@require(lambda numeric_place: numeric_place.a_type is intermediate.PrimitiveType.FLOAT) +def _generate_serialization_failure_test_case( + numeric_place: intermediate.NumericPlace, +) -> Stripped: + """Generate the test that a non-finite floating-point number is refused.""" + mutation, expected_path = _generate_mutation( + numeric_place=numeric_place, value_type="double", zero_literal="0.0" + ) interface_name = cpp_naming.interface_name(numeric_place.cls.name) @@ -174,18 +298,18 @@ def _generate_serialization_failure_test_case( Identifier(f"{numeric_place.cls.name}_from") ) - values_joined = ",\n".join(value_literals) - return Stripped( f"""\ TEST_CASE( -{I}"Test the serialization failure on {what} " +{I}"Test the serialization failure on a non-finite floating-point number " {I}"at {expected_path} of {model_type}" ) {{ {I}for ( -{II}const {value_type} value +{II}const double value {II}: {{ -{III}{indent_but_first_line(values_joined, III)} +{III}std::numeric_limits::infinity(), +{III}-std::numeric_limits::infinity(), +{III}std::numeric_limits::quiet_NaN() {II}}} {I}) {{ {II}std::shared_ptr instance( @@ -206,6 +330,128 @@ def _generate_serialization_failure_test_case( ) +def _generate_safe_integer_test_case( + cls: intermediate.ConcreteClass, + mutation: Stripped, + value_type: str, + expected_path: str, + expected_cause_prefix: str, +) -> Stripped: + """ + Generate the test that a value outside the safe integer range is refused. + + The ``mutation`` sets ``value`` of the ``value_type`` in ``instance``. + """ + interface_name = cpp_naming.interface_name(cls.name) + + model_type = naming.json_model_type(cls.name) + + deserialization_function = cpp_naming.function_name(Identifier(f"{cls.name}_from")) + + load_and_mutate = Stripped( + f"""\ +std::shared_ptr instance( +{I}MustDeserializeTheFirstExpected( +{II}{cpp_common.string_literal(model_type)}, +{II}our::jsonization::{deserialization_function} +{I}) +); + +{mutation}""" + ) + + path_literal = cpp_common.string_literal(expected_path) + + return Stripped( + f"""\ +TEST_CASE( +{I}"Test the safe integer range at {expected_path} of {model_type}" +) {{ +{I}for ( +{II}const int64_t integer +{II}: {{9007199254740992LL, -9007199254740992LL}} +{I}) {{ +{II}const {value_type} value(integer); + +{II}{indent_but_first_line(load_and_mutate, II)} + +{II}AssertReportedEverywhere( +{III}instance, +{III}{path_literal}, +{III}our::common::Concat( +{IIII}{cpp_common.wstring_literal(expected_cause_prefix)}, +{IIII}std::to_wstring(integer), +{IIII}L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" +{III}) +{II}); +{I}}} + +{I}for ( +{II}const int64_t integer +{II}: {{9007199254740991LL, -9007199254740991LL}} +{I}) {{ +{II}const {value_type} value(integer); + +{II}{indent_but_first_line(load_and_mutate, II)} + +{II}AssertNotReportedAnywhere(instance, {path_literal}); +{I}}} +}}""" + ) + + +def _json_able_places( + symbol_table: intermediate.SymbolTable, +) -> List[Tuple[intermediate.ConcreteClass, intermediate.Property]]: + """ + List the required properties which hold a JSON-able value or array. + + An integer outside the safe integer range can sit in such a property. + """ + return [ + (cls, prop) + for cls in symbol_table.concrete_classes + for prop in cls.properties + if isinstance( + prop.type_annotation, + ( + intermediate.JsonValueTypeAnnotation, + intermediate.JsonArrayTypeAnnotation, + ), + ) + ] + + +def _generate_json_able_safe_integer_test_case( + cls: intermediate.ConcreteClass, prop: intermediate.Property +) -> Stripped: + """Generate the test that an integer of a JSON-able value is range-checked.""" + setter_name = cpp_naming.setter_name(prop.name) + + if isinstance(prop.type_annotation, intermediate.JsonArrayTypeAnnotation): + # NOTE (mristin): + # We put the integer at the index 1 so that a check which always reports + # the index 0 does not pass. + mutation = Stripped( + f"""\ +instance->{setter_name}( +{I}nlohmann::json::array({{nlohmann::json(0), value}}) +);""" + ) + expected_path = f".{prop.name}[1]" + else: + mutation = Stripped(f"instance->{setter_name}(value);") + expected_path = f".{prop.name}" + + return _generate_safe_integer_test_case( + cls=cls, + mutation=mutation, + value_type="nlohmann::json", + expected_path=expected_path, + expected_cause_prefix="Expected a JSON-able value, but got the integer ", + ) + + # fmt: off @ensure( lambda result: @@ -221,10 +467,37 @@ def generate_implementation( numeric_places = intermediate.numeric_places(symbol_table) + float_places = [ + numeric_place + for numeric_place in numeric_places + if numeric_place.a_type is intermediate.PrimitiveType.FLOAT + ] + int_places = [ + numeric_place + for numeric_place in numeric_places + if numeric_place.a_type is intermediate.PrimitiveType.INT + ] + json_able_places = _json_able_places(symbol_table) + + checks_safe_integers = len(int_places) > 0 or len(json_able_places) > 0 + # NOTE (mristin): # Only the tests of the serialization failures need the limits of # the floating-point numbers. - limits_include = "\n#include \n" if len(numeric_places) > 0 else "" + limits_include = "\n#include \n" if len(float_places) > 0 else "" + + # NOTE (mristin): + # The tests of the safe integer range check the verification and the XML + # serialization as well. + safe_integer_includes = ( + f""" +#include <{include_prefix_path}/verification.hpp> +#include <{include_prefix_path}/xmlization.hpp> + +#include """ + if checks_safe_integers + else "" + ) blocks: List[Stripped] = [ cpp_common.WARNING, @@ -233,7 +506,7 @@ def generate_implementation( #include "./common.hpp" #include "./common_jsonization.hpp" -#include <{include_prefix_path}/jsonization.hpp> +#include <{include_prefix_path}/jsonization.hpp>{safe_integer_includes} {limits_include} #include @@ -367,9 +640,12 @@ def generate_implementation( ), ] - if len(numeric_places) > 0: + if len(float_places) > 0 or checks_safe_integers: blocks.extend(_generate_serialization_failure_infrastructure()) + if checks_safe_integers: + blocks.extend(_generate_safe_integer_infrastructure()) + for concrete_cls in symbol_table.concrete_classes: interface_name = cpp_naming.interface_name(concrete_cls.name) @@ -447,9 +723,26 @@ def generate_implementation( ) ) - for numeric_place in numeric_places: + for numeric_place in float_places: blocks.append(_generate_serialization_failure_test_case(numeric_place)) + for numeric_place in int_places: + mutation, expected_path = _generate_mutation( + numeric_place=numeric_place, value_type="int64_t", zero_literal="0LL" + ) + blocks.append( + _generate_safe_integer_test_case( + cls=numeric_place.cls, + mutation=mutation, + value_type="int64_t", + expected_path=expected_path, + expected_cause_prefix="Expected an integer, but got ", + ) + ) + + for cls, prop in json_able_places: + blocks.append(_generate_json_able_safe_integer_test_case(cls=cls, prop=prop)) + blocks.append(cpp_common.WARNING) writer = io.StringIO() diff --git a/aas_core_codegen/cpp/tests/_generate_test_verification.py b/aas_core_codegen/cpp/tests/_generate_test_verification.py index d3cdb934d..a552e729f 100644 --- a/aas_core_codegen/cpp/tests/_generate_test_verification.py +++ b/aas_core_codegen/cpp/tests/_generate_test_verification.py @@ -10,6 +10,7 @@ Stripped, ) from aas_core_codegen.cpp import common as cpp_common, naming as cpp_naming +from aas_core_codegen.intermediate import uses as intermediate_uses from aas_core_codegen.cpp.common import ( INDENT as I, INDENT2 as II, @@ -20,6 +21,61 @@ ) +def _generate_safe_integer_predicate_test() -> Stripped: + """Generate the test of the predicates of the safe integer range.""" + return Stripped( + f"""\ +TEST_CASE("Test the predicates of the safe integer range") {{ +{I}for ( +{II}const int64_t value +{II}: {{9007199254740991LL, -9007199254740991LL, 0LL}} +{I}) {{ +{II}REQUIRE(our::common::IsSafeInteger(value)); +{I}}} + +{I}for ( +{II}const int64_t value +{II}: {{ +{III}int64_t(9007199254740992LL), +{III}int64_t(-9007199254740992LL), +{III}std::numeric_limits::max(), +{III}std::numeric_limits::min() +{II}}} +{I}) {{ +{II}REQUIRE(!our::common::IsSafeInteger(value)); +{I}}} + +{I}REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); +{I}REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); +{I}REQUIRE( +{II}!our::common::IsSafeInteger( +{III}std::numeric_limits::max() +{II}) +{I}); + +{I}for ( +{II}const double value +{II}: {{9007199254740991.0, -9007199254740991.0, 0.0}} +{I}) {{ +{II}REQUIRE(our::common::IsSafeInteger(value)); +{I}}} + +{I}for ( +{II}const double value +{II}: {{ +{III}9007199254740992.0, +{III}-9007199254740992.0, +{III}std::numeric_limits::infinity(), +{III}-std::numeric_limits::infinity(), +{III}std::numeric_limits::quiet_NaN() +{II}}} +{I}) {{ +{II}REQUIRE(!our::common::IsSafeInteger(value)); +{I}}} +}}""" + ) + + # fmt: off @ensure( lambda result: @@ -33,6 +89,14 @@ def generate_implementation( """Generate implementation to test the verification of pos. and neg. cases.""" include_prefix_path = cpp_common.generate_include_prefix_path(library_namespace) + # NOTE (mristin): + # Only the test of the safe integer range needs the limits. + limits_include = ( + "\n#include " + if intermediate_uses.safe_integer_checks(symbol_table) + else "" + ) + blocks: List[Stripped] = [ cpp_common.WARNING, Stripped( @@ -45,7 +109,7 @@ def generate_implementation( #include -#include +#include {limits_include} namespace our = {library_namespace};""" ), @@ -237,6 +301,9 @@ def generate_implementation( ) ) + if intermediate_uses.safe_integer_checks(symbol_table): + blocks.append(_generate_safe_integer_predicate_test()) + blocks.append(cpp_common.WARNING) writer = io.StringIO() diff --git a/aas_core_codegen/cpp/tests/_generate_test_xml_rpc.py b/aas_core_codegen/cpp/tests/_generate_test_xml_rpc.py index de6b7920b..67d870065 100644 --- a/aas_core_codegen/cpp/tests/_generate_test_xml_rpc.py +++ b/aas_core_codegen/cpp/tests/_generate_test_xml_rpc.py @@ -10,6 +10,7 @@ INDENT as I, INDENT2 as II, INDENT3 as III, + INDENT4 as IIII, ) @@ -45,7 +46,10 @@ def generate_implementation(library_namespace: Stripped) -> str: #include #include #include +#include #include +#include +#include #pragma warning(pop)""" ), Stripped( @@ -130,6 +134,46 @@ def generate_implementation(library_namespace: Stripped) -> str: {II}); {II}REQUIRE(error.has_value()); {I}}} +}}""" + ), + Stripped( + f"""\ +TEST_CASE("Serialize rejects an integer outside the safe integer range") {{ +{I}// NOTE (mristin): +{I}// We construct the values programmatically, so that we cover both +{I}// the signed and the unsigned integers of ``nlohmann::json``. +{I}const std::vector > cases = {{ +{II}{{nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}}, +{II}{{nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}}, +{II}{{nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"}} +{I}}}; + +{I}for (const std::pair& a_case : cases) {{ +{II}std::ostringstream oss; +{II}common::optional error( +{III}xml_rpc::SerializeValue(oss, a_case.first) +{II}); +{II}REQUIRE(error.has_value()); +{II}REQUIRE( +{III}error->cause +{III}== L"Expected a JSON-able value, but got the integer " + a_case.second +{IIII}+ L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" +{II}); +{I}}} +}}""" + ), + Stripped( + f"""\ +TEST_CASE("Serialize accepts the integers at the bounds of the safe range") {{ +{I}for ( +{II}const int64_t integer : {{9007199254740991LL, -9007199254740991LL}} +{I}) {{ +{II}std::ostringstream oss; +{II}common::optional error( +{III}xml_rpc::SerializeValue(oss, nlohmann::json(integer)) +{II}); +{II}REQUIRE(!error.has_value()); +{I}}} }}""" ), Stripped( diff --git a/aas_core_codegen/cpp/tests/_generate_test_xmlization_of_concrete_classes.py b/aas_core_codegen/cpp/tests/_generate_test_xmlization_of_concrete_classes.py index f5b9e7476..a674c3be9 100644 --- a/aas_core_codegen/cpp/tests/_generate_test_xmlization_of_concrete_classes.py +++ b/aas_core_codegen/cpp/tests/_generate_test_xmlization_of_concrete_classes.py @@ -85,7 +85,11 @@ def _generate_lexical_tests( if a_type in prop_by_a_type ] - if len(relevant) == 0: + # NOTE (mristin): + # The integers additionally have to lie in the safe integer range. + int_prop = prop_by_a_type.get(intermediate.PrimitiveType.INT) + + if len(relevant) == 0 and int_prop is None: return [] interface_name = cpp_naming.interface_name(cls.name) @@ -95,10 +99,10 @@ def _generate_lexical_tests( Stripped( f"""\ /** - * \\brief Read the first recorded example of {interface_name} with the content - * of the element \\p xml_name replaced by \\p text. + * \\brief Patch the first recorded example of {interface_name} so that + * the content of the element \\p xml_name is replaced by \\p text. */ -std::shared_ptr ReadWith( +std::string PatchTheFirstExample( {I}const std::string& xml_name, {I}const std::string& text ) {{ @@ -126,9 +130,20 @@ def _generate_lexical_tests( {II}original.find(our::common::Concat("")) {I}); -{I}const std::string patched( -{II}original.substr(0, start) + text + original.substr(end) -{I}); +{I}return original.substr(0, start) + text + original.substr(end); +}}""" + ), + Stripped( + f"""\ +/** + * \\brief Read the first recorded example of {interface_name} with the content + * of the element \\p xml_name replaced by \\p text. + */ +std::shared_ptr ReadWith( +{I}const std::string& xml_name, +{I}const std::string& text +) {{ +{I}const std::string patched(PatchTheFirstExample(xml_name, text)); {I}std::istringstream iss(patched); @@ -147,7 +162,7 @@ def _generate_lexical_tests( {I}return casted; }}""" - ) + ), ] for a_type, prop in relevant: @@ -173,6 +188,58 @@ def _generate_lexical_tests( ) ) + if int_prop is not None: + prop_xml_name = naming.xml_property(int_prop.name) + getter_name = cpp_naming.getter_name(int_prop.name) + + result.append( + Stripped( + f"""\ +TEST_CASE("Refuse {prop_xml_name} outside the safe integer range") {{ +{I}for ( +{II}const std::string text : {{"9007199254740992", "-9007199254740992"}} +{I}) {{ +{II}std::istringstream iss( +{III}PatchTheFirstExample({cpp_common.string_literal(prop_xml_name)}, text) +{II}); + +{II}our::common::expected< +{III}std::shared_ptr, +{III}our::xmlization::DeserializationError +{II}> deserialized = our::xmlization::From(iss); + +{II}INFO(our::common::Concat("De-serializing ", text)) +{II}REQUIRE(!deserialized.has_value()); +{II}REQUIRE( +{III}deserialized.error().cause +{III}== L"Expected an integer, but got " + our::common::Utf8ToWstring(text) +{IIII}+ L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" +{II}); +{I}}} +}}""" + ) + ) + + result.append( + Stripped( + f"""\ +TEST_CASE("Read {prop_xml_name} at the bounds of the safe integer range") {{ +{I}for ( +{II}const int64_t value : {{9007199254740991LL, -9007199254740991LL}} +{I}) {{ +{II}std::shared_ptr instance( +{III}ReadWith( +{IIII}{cpp_common.string_literal(prop_xml_name)}, +{IIII}std::to_string(value) +{III}) +{II}); + +{II}REQUIRE(instance->{getter_name}() == value); +{I}}} +}}""" + ) + ) + return result diff --git a/aas_core_codegen/csharp/lib/_generate_common.py b/aas_core_codegen/csharp/lib/_generate_common.py index 4a6b8f8c5..440033350 100644 --- a/aas_core_codegen/csharp/lib/_generate_common.py +++ b/aas_core_codegen/csharp/lib/_generate_common.py @@ -2,7 +2,7 @@ import io import textwrap -from typing import Final, List +from typing import Final, List, Sequence from aas_core_codegen import intermediate from aas_core_codegen.common import Stripped, indent_but_first_line @@ -546,7 +546,13 @@ def _generate_string_helpers( /// We accept only an optional sign followed by the ASCII digits, and only /// the safe integers, i.e., the integers within -(2^53 - 1) /// and 2^53 - 1, which a double-precision floating-point number -/// represents exactly. This way, all the SDKs behave the same. +/// represents exactly. +/// +/// +/// We check the safe range even though a long could hold larger +/// integers. TypeScript represents the integers as 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. /// /// /// to be parsed @@ -586,8 +592,7 @@ def _generate_string_helpers( {II}// overflow a long. {II}value = value * 10 + (character - '0'); -{II}// NOTE: This is 2^53 - 1. -{II}if (value > 9007199254740991L) +{II}if (!IsSafeInteger(value)) {II}{{ {III}throw new System.ArgumentException( {IIII}$"Expected a safe integer, but got a text out of its range: {{text}}"); @@ -599,6 +604,72 @@ def _generate_string_helpers( ) +#: Predicates checking the safe integer range [-2^53 + 1, 2^53 - 1] +#: +#: The verification, the de/serialization and ``ParseSafeInt`` all call them, +#: so that each bound is written exactly once in the generated code. +IS_SAFE_INTEGER: Final[Sequence[Stripped]] = [ + Stripped( + f"""\ +/// +/// Check whether is a safe integer, i.e., +/// whether it lies in the range [-2^53 + 1, 2^53 - 1]. +/// +/// +/// JSON knows a single numeric type, which the SDKs in other languages read +/// into a 64-bit floating-point number, so an integer outside this range might +/// be read as a different number. The verification, the de/serialization and +/// all use this check, so that an instance which +/// passes the verification can always be serialized. +/// +/// integer to be checked +/// true if is a safe integer +public static bool IsSafeInteger(long value) +{{ +{I}return -9007199254740991L <= value && value <= 9007199254740991L; +}}""" + ), + Stripped( + f"""\ +/// +/// Check whether is a safe integer. +/// +/// +/// See for what a safe integer is. +/// +/// integer to be checked +/// true if is a safe integer +public static bool IsSafeInteger(ulong value) +{{ +{I}return value <= long.MaxValue && IsSafeInteger((long)value); +}}""" + ), + Stripped( + f"""\ +/// +/// Check whether the whole number lies in the safe +/// integer range. +/// +/// +/// +/// See for what a safe integer is. +/// +/// +/// We check only the range, so the caller has to check first that +/// is a whole number. A not-a-number lies outside +/// the range. +/// +/// +/// whole number to be checked +/// true if is a safe integer +public static bool IsSafeInteger(double value) +{{ +{I}return -9007199254740991.0 <= value && value <= 9007199254740991.0; +}}""" + ), +] + + def _generate_set_helpers(symbol_table: intermediate.SymbolTable) -> Stripped: """ Generate the helpers which sort the items of the set properties. @@ -748,6 +819,12 @@ def generate( if intermediate_uses.modulo(symbol_table): blocks.append(FLOOR_MOD) + # NOTE (mristin): + # The predicates are public so that the clients can rely on them, and so that + # we can unit-test them. + if intermediate_uses.safe_integer_checks(symbol_table): + blocks.extend(IS_SAFE_INTEGER) + # NOTE (mristin): # We add the helper only if the meta-model calls int so that we do not # clutter the code otherwise. diff --git a/aas_core_codegen/csharp/lib/_generate_json_value_verification.py b/aas_core_codegen/csharp/lib/_generate_json_value_verification.py index 313c8a0b6..3ea36223d 100644 --- a/aas_core_codegen/csharp/lib/_generate_json_value_verification.py +++ b/aas_core_codegen/csharp/lib/_generate_json_value_verification.py @@ -20,6 +20,69 @@ ) +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. + """ + return Stripped( + f"""\ +/// +/// Check that , if it is a number at all, is +/// a number of a JSON-able value. +/// +/// +/// JSON knows a single numeric type, which all the other SDKs read into +/// a 64-bit floating-point number. 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. +/// +/// JSON value to be checked +/// +/// Cause why is not JSON-able, or null +/// if it is +/// +internal static string? CheckJsonAbleNumber(Nodes.JsonValue jsonValue) +{{ +{I}// NOTE (mristin): +{I}// We can not switch on the kind of the number, since +{I}// ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated +{I}// code has to build against .NET 6. +{I}if (jsonValue.TryGetValue(out long integer)) +{I}{{ +{II}if (!Common.IsSafeInteger(integer)) +{II}{{ +{III}return UnsafeIntegerMessage(integer); +{II}}} +{I}}} +{I}else if (jsonValue.TryGetValue(out ulong unsignedInteger)) +{I}{{ +{II}if (!Common.IsSafeInteger(unsignedInteger)) +{II}{{ +{III}return UnsafeIntegerMessage(unsignedInteger); +{II}}} +{I}}} +{I}else if (jsonValue.TryGetValue(out double number)) +{I}{{ +{II}// NOTE (mristin): +{II}// JSON knows neither an infinity nor a not-a-number. A conformant parser +{II}// never gives us one, but the node may have been constructed +{II}// programmatically. +{II}if (!System.Double.IsFinite(number)) +{II}{{ +{III}return $"Expected a JSON-able value, but got the number {{number}}, " + +{IIII}"which is neither finite nor representable in JSON"; +{II}}} +{I}}} + +{I}return null; +}}""" + ) + + # fmt: off @ensure( lambda result: result.endswith('\n'), @@ -50,6 +113,25 @@ def generate(namespace: csharp_common.NamespaceIdentifier) -> str: Stripped( f"""\ /// +/// Report that the integer of a JSON-able value is +/// not a safe integer. +/// +/// +/// See for what a safe +/// integer is. +/// +/// integer outside the safe integer range +/// the cause of the error +private static string UnsafeIntegerMessage(System.IFormattable 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]"; +}}""" + ), + _generate_check_json_able_number(), + Stripped( + f"""\ +/// /// Represent the expected top-level shape of a JSON-able value. /// public enum ExpectedShape @@ -74,14 +156,12 @@ def generate(namespace: csharp_common.NamespaceIdentifier) -> str: Stripped( f"""\ /// -/// Verify that holds no non-finite number anywhere, -/// at any depth. +/// Verify that holds no number anywhere, at any depth, +/// which is not a number of a JSON-able value. /// /// -/// JSON knows neither an infinity nor a not-a-number -- yet -/// holds either happily if it was put -/// together programmatically. A null is a JSON null, and hence -/// a JSON-able value. +/// See for what a JSON-able number is. +/// A null is a JSON null, and hence a JSON-able value. /// private static IEnumerable VerifyRecursively( {I}Nodes.JsonNode that) @@ -127,17 +207,12 @@ def generate(namespace: csharp_common.NamespaceIdentifier) -> str: {III}break; {II}case Nodes.JsonValue jsonValue: -{III}// NOTE (mristin): -{III}// JSON knows neither an infinity nor a not-a-number, so neither is -{III}// a JSON-able value, even though Nodes.JsonValue holds one happily -{III}// if it was constructed programmatically. -{III}if ( -{IIII}jsonValue.TryGetValue(out double number) -{IIIII}&& !System.Double.IsFinite(number)) {III}{{ -{IIII}yield return new Reporting.Error( -{IIIII}$"Expected a JSON-able value, but got the number {{number}}, " + -{IIIII}"which is neither finite nor representable in JSON"); +{IIII}string? cause = CheckJsonAbleNumber(jsonValue); +{IIII}if (cause != null) +{IIII}{{ +{IIIII}yield return new Reporting.Error(cause); +{IIII}}} {III}}} {III}break; @@ -151,8 +226,9 @@ def generate(namespace: csharp_common.NamespaceIdentifier) -> str: f"""\ /// /// Verify that is JSON-able (recursively -/// a null, a boolean, a finite number, a string, an array or an object of -/// JSON-able values -- never a non-finite number, at any depth) and, if +/// a null, a boolean, a number, a string, an array or an object of +/// JSON-able values -- never an infinity, a not-a-number or an integer outside +/// the safe integer range [-2^53 + 1, 2^53 - 1], at any depth) and, if /// is not /// , that its top-level shape matches. /// diff --git a/aas_core_codegen/csharp/lib/_generate_jsonization.py b/aas_core_codegen/csharp/lib/_generate_jsonization.py index 30c14ba0c..7fd9ab56c 100644 --- a/aas_core_codegen/csharp/lib/_generate_jsonization.py +++ b/aas_core_codegen/csharp/lib/_generate_jsonization.py @@ -389,6 +389,96 @@ def _generate_primitive_converter( description = _PRIMITIVE_TYPE_DESCRIPTION[primitive_type] value_type = csharp_common.PRIMITIVE_TYPE_MAP[primitive_type] + if primitive_type is intermediate.PrimitiveType.INT: + # NOTE (mristin): + # An integer is the only primitive which a JSON node can give us in two + # kinds, as an integer or as a float, and it has to be checked for + # the safe integer range, so it does not share the scaffolding of + # the other primitives below. + return Stripped( + f"""\ +/// +/// Convert to {description}. +/// +/// +/// A whole number written as a float, such as 5.0 or 1e2, is +/// accepted as well. An integer outside the safe integer range +/// [-2^53 + 1, 2^53 - 1] is refused, see +/// . +/// +/// JSON node to be parsed +/// Error, if any, during the deserialization +internal static {value_type} {name}( +{I}Nodes.JsonNode? node, +{I}out Reporting.Error? error) +{{ +{I}error = null; + +{I}// NOTE (mristin): +{I}// We can not switch on the kind of the number, since +{I}// ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated +{I}// code has to build against .NET 6. +{I}Nodes.JsonValue? value = node as Nodes.JsonValue; +{I}if (value != null) +{I}{{ +{II}if (value.TryGetValue(out long integer)) +{II}{{ +{III}if (!Common.IsSafeInteger(integer)) +{III}{{ +{IIII}error = new Reporting.Error( +{IIIII}Verification.UnsafeIntegerMessage(integer)); +{IIII}return default; +{III}}} +{III}return integer; +{II}}} +{II}else if (value.TryGetValue(out int smallInteger)) +{II}{{ +{III}// NOTE (mristin): +{III}// Only a node constructed programmatically holds an int, which is +{III}// always a safe integer. +{III}return smallInteger; +{II}}} +{II}else if (value.TryGetValue(out ulong unsignedInteger)) +{II}{{ +{III}// NOTE (mristin): +{III}// Only a node constructed programmatically holds an ulong, as a parsed +{III}// integer beyond the range of a long is read as a double below. +{III}if (!Common.IsSafeInteger(unsignedInteger)) +{III}{{ +{IIII}error = new Reporting.Error( +{IIIII}Verification.UnsafeIntegerMessage(unsignedInteger)); +{IIII}return default; +{III}}} +{III}return (long)unsignedInteger; +{II}}} +{II}else if (value.TryGetValue(out double number)) +{II}{{ +{III}if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) +{III}{{ +{IIII}error = new Reporting.Error( +{IIIII}$"Expected an integer, but got {{number}}, which is not a whole number"); +{IIII}return default; +{III}}} + +{III}// NOTE (mristin): +{III}// We have to check the range before the cast, as the cast of a number +{III}// outside the range of a long gives an unspecified value. +{III}if (!Common.IsSafeInteger(number)) +{III}{{ +{IIII}error = new Reporting.Error( +{IIIII}Verification.UnsafeIntegerMessage(number)); +{IIII}return default; +{III}}} +{III}return (long)number; +{II}}} +{I}}} + +{I}error = new Reporting.Error( +{II}$"Expected an integer, but got: {{Describe(node)}}"); +{I}return default; +}}""" + ) + conversion_failed = Stripped( f"""\ error = new Reporting.Error( @@ -1339,18 +1429,10 @@ def _generate_deserialize_json_helpers() -> List[Stripped]: {III}return true; {II}case Nodes.JsonValue jsonValue: -{III}// NOTE (mristin): -{III}// JSON knows neither an infinity nor a not-a-number, so neither is -{III}// a JSON-able value. A conformant parser can never produce one, but -{III}// the caller gives us a node which may have been constructed -{III}// programmatically. -{III}if ( -{IIII}jsonValue.TryGetValue(out double number) -{IIIII}&& !System.Double.IsFinite(number)) +{III}string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); +{III}if (cause != null) {III}{{ -{IIII}error = new Reporting.Error( -{IIIII}$"Expected a JSON-able value, but got the number {{number}}, " + -{IIIII}"which is neither finite nor representable in JSON"); +{IIII}error = new Reporting.Error(cause); {IIII}return false; {III}}} {III}return true; @@ -1478,7 +1560,14 @@ def _generate_deserialize_impl( blocks: List[Stripped] = [_generate_describe_helper()] + # NOTE (mristin): + # The converter of an integer relies on the safe integer predicate and + # message of ``Verification``, which exist only if the model holds integers. + holds_integers = intermediate_uses.integers(symbol_table) for primitive_type in intermediate.PrimitiveType: + if primitive_type is intermediate.PrimitiveType.INT and not holds_integers: + continue + blocks.append(_generate_primitive_converter(primitive_type)) # region Shared combinators and the de-serializers they compose @@ -1641,11 +1730,81 @@ def _generate_deserialize_from(name: str) -> Stripped: return Stripped(writer.getvalue()) +def _generate_deserialize_json_able_from( + name: str, result_type: str, node_type: str +) -> Stripped: + """ + Generate the facade deserialization method for the JSON-able ``name``. + + The ``name`` is ``JsonValue``, ``JsonArray`` or ``JsonObject``. The method + gives back a node of ``result_type`` from a node of ``node_type``. + """ + what = name[len("Json") :].lower() + + return Stripped( + f"""\ +/// +/// Deserialize a JSON-able {what} from . +/// +/// +/// A JSON-able value is, recursively, exactly as JSON itself is defined: +/// a null (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, independent copy of . +/// +/// JSON node to be parsed +/// +/// Thrown when is not a JSON-able +/// {what}. +/// +public static {result_type} {name}From( +{I}{node_type} node) +{{ +{I}{result_type} result = DeserializeImplementation.{name}From( +{II}node, +{II}out Reporting.Error? error); +{I}if (error != null) +{I}{{ +{II}throw new Jsonization.Exception( +{III}Reporting.GenerateJsonPath(error.PathSegments), +{III}error.Cause); +{I}}} +{I}return result; +}}""" + ) + + 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_from( + name="JsonValue", + result_type="Nodes.JsonNode?", + node_type="Nodes.JsonNode?", + ) + ) + blocks.append( + _generate_deserialize_json_able_from( + name="JsonArray", + result_type="Nodes.JsonArray", + node_type="Nodes.JsonNode", + ) + ) + blocks.append( + _generate_deserialize_json_able_from( + name="JsonObject", + result_type="Nodes.JsonObject", + node_type="Nodes.JsonNode", + ) + ) for our_type in symbol_table.our_types: if isinstance(our_type, intermediate.Enumeration): blocks.append( @@ -2606,18 +2765,10 @@ def _generate_serialize_json_helpers() -> List[Stripped]: {III}break; {II}case Nodes.JsonValue jsonValue: -{III}// NOTE (mristin): -{III}// JSON knows neither an infinity nor a not-a-number, so neither is -{III}// a JSON-able value, even though Nodes.JsonValue holds one happily -{III}// if it was constructed programmatically. -{III}if ( -{IIII}jsonValue.TryGetValue(out double number) -{IIIII}&& !System.Double.IsFinite(number)) +{III}string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); +{III}if (cause != null) {III}{{ -{IIII}throw new SerializationFailure( -{IIIII}new Reporting.Error( -{IIIIII}$"Expected a JSON-able value, but got the number {{number}}, " + -{IIIIII}"which is neither finite nor representable in JSON")); +{IIII}throw new SerializationFailure(new Reporting.Error(cause)); {III}}} {III}break; @@ -2640,7 +2791,7 @@ def _generate_serialize_json_helpers() -> List[Stripped]: /// Thrown if contains a non-finite number anywhere, /// at any depth. /// -private static Nodes.JsonNode? SerializeJsonValue(Nodes.JsonNode? that) +internal static Nodes.JsonNode? SerializeJsonValue(Nodes.JsonNode? that) {{ {I}if (that == null) {I}{{ @@ -2746,55 +2897,60 @@ def _generate_transformer( Stripped( f"""\ /// -/// Convert 64-bit long integer to a JSON value. +/// Convert 64-bit floating-point number to a JSON +/// value. /// /// value to be converted /// -/// Thrown if lies outside the range where it can 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. +/// Thrown if is not finite. JSON knows neither +/// an infinity nor a not-a-number, so we refuse them here instead of +/// leaving it to System.Text.Json, which throws much later -- when +/// the caller writes the document out -- and says nothing about where +/// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] -private static Nodes.JsonValue ToJsonValue(long that) +private static Nodes.JsonValue ToJsonValue(double that) {{ -{I}if (that < -9007199254740991L || that > 9007199254740991L) +{I}if (!System.Double.IsFinite(that)) {I}{{ {II}throw new SerializationFailure( {III}new Reporting.Error( -{IIII}"The integer can not be serialized to JSON as it is outside " + -{IIII}$"the range [-2^53 + 1, 2^53 - 1]: {{that}}")); +{IIII}"JSON knows neither an infinity nor a not-a-number, so " + +{IIII}$"the value can not be serialized: {{that}}")); {I}}} {I}return Nodes.JsonValue.Create(that); }}""" ), - Stripped( - f"""\ + ] + + # NOTE (mristin): + # The integers are range-checked with the predicate of ``Common`` and the + # message of ``Verification``, which exist only if the model holds integers. + if intermediate_uses.integers(symbol_table): + blocks.append( + Stripped( + f"""\ /// -/// Convert 64-bit floating-point number to a JSON -/// value. +/// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// -/// Thrown if is not finite. JSON knows neither -/// an infinity nor a not-a-number, so we refuse them here instead of -/// leaving it to System.Text.Json, which throws much later -- when -/// the caller writes the document out -- and says nothing about where -/// the offending value sat. +/// Thrown if lies outside the safe integer range, +/// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] -private static Nodes.JsonValue ToJsonValue(double that) +private static Nodes.JsonValue ToJsonValue(long that) {{ -{I}if (!System.Double.IsFinite(that)) +{I}if (!Common.IsSafeInteger(that)) {I}{{ {II}throw new SerializationFailure( {III}new Reporting.Error( -{IIII}"JSON knows neither an infinity nor a not-a-number, so " + -{IIII}$"the value can not be serialized: {{that}}")); +{IIII}Verification.UnsafeIntegerMessage(that))); {I}}} {I}return Nodes.JsonValue.Create(that); }}""" - ), - ] + ) + ) if len(symbol_table.named_unions) > 0: blocks.append(_generate_transform_iunion_helper()) @@ -2941,6 +3097,38 @@ def _generate_serialize( ), ] + if intermediate_uses.json_types(symbol_table): + blocks.append( + Stripped( + f"""\ +/// +/// Serialize the JSON-able value into a JSON node. +/// +/// +/// The result is a deep, independent copy of . +/// A null is a JSON null. +/// +/// +/// Thrown when contains, at any depth, an infinity, +/// a not-a-number or an integer outside the safe integer range +/// [-2^53 + 1, 2^53 - 1] +/// +public static Nodes.JsonNode? JsonValueToJsonNode(Nodes.JsonNode? that) +{{ +{I}try +{I}{{ +{II}return Transformer.SerializeJsonValue(that); +{I}}} +{I}catch (SerializationFailure failure) +{I}{{ +{II}throw new SerializationException( +{III}Reporting.GenerateCSharpPath(failure.Error.PathSegments), +{III}failure.Error.Cause); +{I}}} +}}""" + ) + ) + for enum in symbol_table.enumerations: name = csharp_naming.enum_name(enum.name) blocks.append( diff --git a/aas_core_codegen/csharp/lib/_generate_verification.py b/aas_core_codegen/csharp/lib/_generate_verification.py index bf83c991c..560d73e16 100644 --- a/aas_core_codegen/csharp/lib/_generate_verification.py +++ b/aas_core_codegen/csharp/lib/_generate_verification.py @@ -2,6 +2,7 @@ import io import textwrap from typing import ( + Final, Tuple, Optional, List, @@ -769,12 +770,28 @@ def _generate_verify_method(our_type: intermediate.OurType) -> Stripped: raise AssertionError("Unexpected execution path") +def _is_int(type_annotation: intermediate.TypeAnnotationUnion) -> bool: + """ + Check whether ``type_annotation`` is the bare primitive ``int``. + + A constrained primitive over ``int`` is not included, as it is verified by + its own method, which checks the safe integer range itself. + """ + return ( + isinstance(type_annotation, intermediate.PrimitiveTypeAnnotation) + and type_annotation.a_type is intermediate.PrimitiveType.INT + ) + + def _needs_verification(type_annotation: intermediate.TypeAnnotationUnion) -> bool: """ Check whether a value of ``type_annotation`` has anything to verify at any depth. In C#, we verify all our types, including the enumerations, as any integer - can be cast to an enumeration, and all the JSON-able values. + can be cast to an enumeration, and all the JSON-able values. We also verify + the integers, since a ``long`` outside the safe integer range can not be + serialized to JSON, and the verification has to catch whatever + the serialization would refuse. This check is specific to C#, and hence does not live in :py:mod:`aas_core_codegen.intermediate`: other targets verify different @@ -791,6 +808,7 @@ def _needs_verification(type_annotation: intermediate.TypeAnnotationUnion) -> bo intermediate.JsonObjectTypeAnnotation, ), ) + or _is_int(type_anno) for type_anno in intermediate.over_type_annotation_and_nested_type_annotations( type_annotation ) @@ -851,6 +869,16 @@ def _verification_moniker(type_anno: intermediate.TypeAnnotationUnion) -> str: return csharp_common.leaf_moniker(type_anno) +# NOTE (mristin): +# The verification of a bare ``long`` is named by its moniker just like +# the verification of a container (*e.g.*, ``Verify_ListOf_long``), so that it +# can never clash with the verification of a constrained primitive, say, +# named ``Long``. +_VERIFY_LONG: Final[Identifier] = Identifier( + f"Verify_{csharp_common.PRIMITIVE_TYPE_TO_MONIKER[intermediate.PrimitiveType.INT]}" +) + + def _verify_container_name( type_anno: intermediate.ContainerTypeAnnotation, ) -> Identifier: @@ -881,6 +909,10 @@ def _generate_verify_into( method = _generate_verify_method(our_type=type_anno.our_type) args = [expr] + elif _is_int(type_anno): + method = f"Verification.{_VERIFY_LONG}" + args = [expr] + elif isinstance(type_anno, intermediate.JsonValueTypeAnnotation): method = "JsonValueVerification.Verify" args = [expr, "JsonValueVerification.ExpectedShape.Any"] @@ -1270,6 +1302,21 @@ def _generate_verify_constrained_primitive( if len(errors) > 0: return None, errors + if constrained_primitive.constrainee is intermediate.PrimitiveType.INT: + # NOTE (mristin): + # The constrainee has to be serializable to JSON, regardless of + # the invariants, so we check the safe integer range first. + blocks.insert( + 0, + Stripped( + f"""\ +foreach (var error in {_VERIFY_LONG}(that)) +{{ +{I}yield return error; +}}""" + ), + ) + if len(blocks) == 0: blocks.append( Stripped( @@ -1514,6 +1561,47 @@ def generate( if len(symbol_table.named_unions) > 0: verification_blocks.append(_generate_union_verify_helper()) + if intermediate_uses.integers(symbol_table): + verification_blocks.append( + Stripped( + f"""\ +/// +/// Report that the integer is not a safe integer. +/// +/// +/// See for what a safe integer is. +/// +/// integer outside the safe integer range +/// the cause of the error +internal static string UnsafeIntegerMessage(System.IFormattable value) +{{ +{I}return $"Expected an integer, but got {{value}}, " + +{II}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; +}}""" + ) + ) + + verification_blocks.append( + Stripped( + f"""\ +/// +/// Verify that lies in the safe integer range. +/// +/// +/// See for what a safe integer is. +/// +private static IEnumerable {_VERIFY_LONG}( +{I}long that) +{{ +{I}if (!Common.IsSafeInteger(that)) +{I}{{ +{II}yield return new Reporting.Error( +{III}UnsafeIntegerMessage(that)); +{I}}} +}}""" + ) + ) + observed_monikers: Set[str] = set() for cls in symbol_table.concrete_classes: diff --git a/aas_core_codegen/csharp/lib/_generate_xml_rpc.py b/aas_core_codegen/csharp/lib/_generate_xml_rpc.py index 5e733eced..87be2e23b 100644 --- a/aas_core_codegen/csharp/lib/_generate_xml_rpc.py +++ b/aas_core_codegen/csharp/lib/_generate_xml_rpc.py @@ -15,7 +15,6 @@ INDENT4 as IIII, INDENT5 as IIIII, INDENT6 as IIIIII, - INDENT7 as IIIIIII, ) @@ -780,6 +779,17 @@ def generate(namespace: csharp_common.NamespaceIdentifier) -> str: {III}break; {II}case Nodes.JsonValue jsonValue: +{III}// NOTE (mristin): +{III}// We check a number exactly as the verification and the JSON +{III}// de/serialization do. Otherwise, an integer outside the safe integer +{III}// range would be quietly written as a different double below. +{III}string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); +{III}if (cause != null) +{III}{{ +{IIII}throw new SerializationFailure( +{IIIII}new Reporting.Error(cause)); +{III}}} + {III}if (jsonValue.TryGetValue(out bool boolValue)) {III}{{ {IIII}writer.WriteStartElement("boolean", ""); @@ -792,22 +802,28 @@ def generate(namespace: csharp_common.NamespaceIdentifier) -> str: {IIII}writer.WriteString(boolValue ? "1" : "0"); {IIII}writer.WriteEndElement(); {III}}} +{III}else if (jsonValue.TryGetValue(out long integer)) +{III}{{ +{IIII}// NOTE (mristin): +{IIII}// XML-RPC carries every JSON number in a , and the check +{IIII}// above made sure that the integer is represented exactly. +{IIII}writer.WriteStartElement("double", ""); +{IIII}writer.WriteValue(integer); +{IIII}writer.WriteEndElement(); +{III}}} +{III}else if (jsonValue.TryGetValue(out ulong unsignedInteger)) +{III}{{ +{IIII}writer.WriteStartElement("double", ""); +{IIII}writer.WriteValue((long)unsignedInteger); +{IIII}writer.WriteEndElement(); +{III}}} {III}else if (jsonValue.TryGetValue(out double doubleValue)) {III}{{ {IIII}// NOTE (mristin): -{IIII}// JSON knows neither an infinity nor a not-a-number, so neither -{IIII}// is a JSON-able value. XmlWriter would happily write "INF" or -{IIII}// "NaN" -- the xs:double spellings -- which -{IIII}// DeserializeDoubleTextFrom deliberately refuses to read back. -{IIII}if (!System.Double.IsFinite(doubleValue)) -{IIII}{{ -{IIIII}throw new SerializationFailure( -{IIIIII}new Reporting.Error( -{IIIIIII}"Expected a JSON-able value, but got the number " + -{IIIIIII}$"{{doubleValue}}, which is neither finite nor " + -{IIIIIII}"representable in JSON")); -{IIII}}} - +{IIII}// The check above refused an infinity and a not-a-number, which +{IIII}// XmlWriter would otherwise happily write as "INF" or "NaN" -- the +{IIII}// xs:double spellings -- and which DeserializeDoubleTextFrom +{IIII}// deliberately refuses to read back. {IIII}writer.WriteStartElement("double", ""); {IIII}writer.WriteValue(doubleValue); {IIII}writer.WriteEndElement(); diff --git a/aas_core_codegen/csharp/lib/_generate_xmlization.py b/aas_core_codegen/csharp/lib/_generate_xmlization.py index d2dfd6b6c..420a9da7e 100644 --- a/aas_core_codegen/csharp/lib/_generate_xmlization.py +++ b/aas_core_codegen/csharp/lib/_generate_xmlization.py @@ -649,6 +649,47 @@ def _generate_content_converters( private static {csharp_type} {function_name}(Xml.XmlReader reader) {{ {I}return {conversion_expr}; +}}""" + ) + ) + + if intermediate.PrimitiveType.INT in primitive_types: + result.append( + Stripped( + f"""\ +/// +/// Refuse an integer read by if it lies +/// outside the safe integer range. +/// +/// +/// The lexical rules of xs:long are left to , +/// while the range is checked exactly as in the JSON de-serialization, see +/// . +/// +private static ContentReader AsSafeInteger( +{I}ContentReader readInteger +{I}) +{{ +{I}return ( +{II}Xml.XmlReader reader, +{II}bool isEmpty, +{II}out Reporting.Error? error +{I}) => +{I}{{ +{II}long result = readInteger(reader, isEmpty, out error); +{II}if (error != null) +{II}{{ +{III}return default; +{II}}} + +{II}if (!Common.IsSafeInteger(result)) +{II}{{ +{III}error = new Reporting.Error( +{IIII}Verification.UnsafeIntegerMessage(result)); +{III}return default; +{II}}} +{II}return result; +{I}}}; }}""" ) ) @@ -1008,6 +1049,14 @@ def _content_reader_initializer( arguments = content_reader if empty_value is not None: arguments = f"{arguments}, {empty_value}" + + if primitive_type is intermediate.PrimitiveType.INT: + return Stripped( + f"""\ +AsSafeInteger( +{I}AsText<{csharp_type}>({arguments}))""" + ) + return Stripped(f"AsText<{csharp_type}>({arguments})") if isinstance(type_anno, intermediate.OurTypeAnnotation): @@ -2706,6 +2755,24 @@ def _content_writer_initializer( return Stripped("XmlRpc.SerializeStructBodyTo") primitive_type = intermediate.try_primitive_type(type_anno) + if primitive_type is intermediate.PrimitiveType.INT: + # NOTE (mristin): + # We refuse an integer outside the safe integer range exactly as + # the JSON serialization does, so that the two never disagree. + return Stripped( + f"""\ +(that, writer) => +{{ +{I}if (!Common.IsSafeInteger(that)) +{I}{{ +{II}throw new SerializationFailure( +{III}new Reporting.Error( +{IIII}Verification.UnsafeIntegerMessage(that))); +{I}}} +{I}{_WRITE_VALUE_BY_PRIMITIVE[primitive_type]}; +}}""" + ) + if primitive_type is not None: return Stripped( f"(that, writer) => {_WRITE_VALUE_BY_PRIMITIVE[primitive_type]}" diff --git a/aas_core_codegen/csharp/main.py b/aas_core_codegen/csharp/main.py index a07877583..80f2bcd07 100644 --- a/aas_core_codegen/csharp/main.py +++ b/aas_core_codegen/csharp/main.py @@ -372,6 +372,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) + [ + ( + tests_rel_path / "TestSafeIntegers.cs", + lambda: ( + csharp_tests.generate_test_safe_integers( + namespace=namespace, symbol_table=context.symbol_table + ), + None, + ), + ), + ] + # NOTE (mristin): # We test the arithmetic operations only if the meta-model uses them, as we # generate the helper functions only in that case. diff --git a/aas_core_codegen/csharp/tests/__init__.py b/aas_core_codegen/csharp/tests/__init__.py index d928247f3..8fc9842f0 100644 --- a/aas_core_codegen/csharp/tests/__init__.py +++ b/aas_core_codegen/csharp/tests/__init__.py @@ -13,6 +13,7 @@ _generate_test_jsonization_of_enums, _generate_test_jsonization_of_interfaces, _generate_test_parse_int, + _generate_test_safe_integers, _generate_test_over_x_or_empty, _generate_test_string_helpers, _generate_test_verification_of_enums, @@ -41,6 +42,7 @@ _generate_test_jsonization_of_interfaces.generate ) generate_test_parse_int = _generate_test_parse_int.generate +generate_test_safe_integers = _generate_test_safe_integers.generate generate_test_over_x_or_empty = _generate_test_over_x_or_empty.generate generate_test_string_helpers = _generate_test_string_helpers.generate generate_test_verification_of_enums = _generate_test_verification_of_enums.generate diff --git a/aas_core_codegen/csharp/tests/_generate_test_jsonization_of_concrete_classes.py b/aas_core_codegen/csharp/tests/_generate_test_jsonization_of_concrete_classes.py index 261df59e7..13f79b728 100644 --- a/aas_core_codegen/csharp/tests/_generate_test_jsonization_of_concrete_classes.py +++ b/aas_core_codegen/csharp/tests/_generate_test_jsonization_of_concrete_classes.py @@ -16,6 +16,59 @@ ) +def generate_numeric_place_mutation( + numeric_place: intermediate.NumericPlace, +) -> Stripped: + """ + Generate the statement putting ``value`` at the ``numeric_place`` of ``instance``. + + The generated tests of the safe integers put their values at the very same + places, so they share this. + """ + prop_name = csharp_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: + if numeric_place.a_type is intermediate.PrimitiveType.FLOAT: + value_type = "double" + zero_literal = "0.0" + else: + value_type = "long" + zero_literal = "0L" + + # 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} = new List<{value_type}>() {{ {items_joined} }};" + ) + + 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}" + ) + + items_joined = ",\n".join( + "value" if i == numeric_place.index else f"instance.{prop_name}.Item{i + 1}" + for i in range(len(tuple_type_anno.items)) + ) + + return Stripped( + f"""\ +instance.{prop_name} = ( +{I}{indent_but_first_line(items_joined, I)});""" + ) + + def _generate_serialization_failure_tests( symbol_table: intermediate.SymbolTable, ) -> List[Stripped]: @@ -25,7 +78,6 @@ def _generate_serialization_failure_tests( for numeric_place in intermediate.numeric_places(symbol_table): if numeric_place.a_type is intermediate.PrimitiveType.FLOAT: value_type = "double" - zero_literal = "0.0" what = "non_finite" values_literal = ( "{ System.Double.PositiveInfinity, " @@ -33,50 +85,17 @@ def _generate_serialization_failure_tests( ) else: value_type = "long" - zero_literal = "0L" what = "out_of_range" values_literal = "{ 9007199254740992L, -9007199254740992L }" prop_name = csharp_naming.property_name(numeric_place.prop.name) - if numeric_place.index is None: - mutation = Stripped(f"instance.{prop_name} = value;") - expected_path = f"{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} = " - f"new List<{value_type}>() {{ {items_joined} }};" - ) - expected_path = f"{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}" - ) - - items_joined = ",\n".join( - "value" - if i == numeric_place.index - else f"instance.{prop_name}.Item{i + 1}" - for i in range(len(tuple_type_anno.items)) - ) - - mutation = Stripped( - f"""\ -instance.{prop_name} = ( -{I}{indent_but_first_line(items_joined, I)});""" - ) - expected_path = f"{prop_name}[{numeric_place.index}]" + mutation = generate_numeric_place_mutation(numeric_place) + expected_path = ( + prop_name + if numeric_place.index is None + else f"{prop_name}[{numeric_place.index}]" + ) cls_name_json = naming.json_model_type(numeric_place.cls.name) diff --git a/aas_core_codegen/csharp/tests/_generate_test_safe_integers.py b/aas_core_codegen/csharp/tests/_generate_test_safe_integers.py new file mode 100644 index 000000000..4564d04a2 --- /dev/null +++ b/aas_core_codegen/csharp/tests/_generate_test_safe_integers.py @@ -0,0 +1,469 @@ +"""Generate code to test that the integers are refused outside the safe integer range.""" + +from typing import List + +from icontract import ensure + +from aas_core_codegen import intermediate, naming +from aas_core_codegen.common import Identifier, Stripped, indent_but_first_line +from aas_core_codegen.csharp import common as csharp_common, naming as csharp_naming +from aas_core_codegen.csharp.common import ( + INDENT as I, + INDENT2 as II, + INDENT3 as III, + INDENT4 as IIII, +) +from aas_core_codegen.csharp.tests import ( + _generate_test_jsonization_of_concrete_classes as csharp_tests_jsonization, +) +from aas_core_codegen.intermediate import uses as intermediate_uses + + +# fmt: off +@ensure( + lambda result: result.endswith('\n'), + "Trailing newline mandatory for valid end-of-files" +) +# fmt: on +def generate( + namespace: csharp_common.NamespaceIdentifier, + symbol_table: intermediate.SymbolTable, +) -> str: + """ + Generate code to test that the integers are refused outside the safe integer range. + + We test the predicate ``Common.IsSafeInteger`` directly. Then we set + an integer, or an integer in a JSON-able value, from outside, and + check that the verification, the JSON serialization and the XML + serialization all refuse it with the same message, and that they all + accept the integers at the bounds of the range. We also check that + the de-serialization from JSON and from XML refuses the integers right + beyond the bounds. + + The ``namespace`` indicates the fully-qualified name of the base project. + """ + blocks: List[Stripped] = [ + Stripped( + f"""\ +[Test] +public void Test_IsSafeInteger() +{{ +{I}foreach (var value in new long[] +{I}{{ +{II}0L, 9007199254740991L, -9007199254740991L +{I}}}) +{I}{{ +{II}Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {{value}}"); +{I}}} + +{I}foreach (var value in new long[] +{I}{{ +{II}9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue +{I}}}) +{I}{{ +{II}Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {{value}}"); +{I}}} + +{I}Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); +{I}Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); +{I}Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + +{I}foreach (var value in new double[] +{I}{{ +{II}0.0, 9007199254740991.0, -9007199254740991.0 +{I}}}) +{I}{{ +{II}Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {{value}}"); +{I}}} + +{I}foreach (var value in new double[] +{I}{{ +{II}9007199254740992.0, +{II}-9007199254740992.0, +{II}double.PositiveInfinity, +{II}double.NegativeInfinity, +{II}double.NaN +{I}}}) +{I}{{ +{II}Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {{value}}"); +{I}}} +}} // public void Test_IsSafeInteger""" + ), + Stripped( + f"""\ +/// +/// Read the first recorded example of the . +/// +private static Nodes.JsonNode LoadTheFirstExpected(string modelType) +{{ +{I}var paths = Directory.GetFiles( +{II}Path.Combine( +{III}Our.Tests.Common.TestDataDir, +{III}"Json", +{III}"Expected", +{III}modelType), +{II}"*.json", +{II}System.IO.SearchOption.AllDirectories).ToList(); +{I}paths.Sort(); + +{I}Assert.IsNotEmpty( +{II}paths, +{II}$"Expected at least one recorded example of {{modelType}}, but got none"); + +{I}return Our.Tests.CommonJson.ReadFromFile(paths[0]); +}}""" + ), + Stripped( + f"""\ +private static string SerializeToXml(Our.IClass instance) +{{ +{I}var builder = new System.Text.StringBuilder(); +{I}using (var writer = System.Xml.XmlWriter.Create( +{II}builder, +{II}new System.Xml.XmlWriterSettings() +{II}{{ +{III}Encoding = System.Text.Encoding.UTF8, +{III}OmitXmlDeclaration = true +{II}}})) +{I}{{ +{II}Our.Xmlization.Serialize.To(instance, writer); +{I}}} +{I}return builder.ToString(); +}}""" + ), + Stripped( + f"""\ +/// +/// Assert that the verification, the JSON serialization and the XML +/// serialization all refuse with +/// . +/// +private static void AssertRefused( +{I}Our.IClass instance, +{I}string expectedCause, +{I}string expectedJsonPath) +{{ +{I}var errors = Our.Verification.Verify(instance).ToList(); +{I}Assert.IsTrue( +{II}errors.Any( +{III}error => +{IIII}error.Cause == expectedCause +{IIII}&& Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), +{II}$"Expected the verification error {{expectedCause}} " + +{II}$"at {{expectedJsonPath}}, but got: " + +{II}string.Join( +{III}"; ", +{III}errors.Select( +{IIII}error => $"{{Reporting.GenerateJsonPath(error.PathSegments)}}: {{error.Cause}}"))); + +{I}var jsonException = Assert.Throws( +{II}() => Our.Jsonization.Serialize.ToJsonObject(instance)); +{I}Assert.AreEqual(expectedCause, jsonException!.Cause); + +{I}var xmlException = Assert.Throws( +{II}() => SerializeToXml(instance)); +{I}Assert.AreEqual(expectedCause, xmlException!.Cause); +}}""" + ), + Stripped( + f"""\ +/// +/// Assert that neither the verification, nor the JSON serialization nor +/// the XML serialization refuses an integer of +/// as outside the safe integer range. +/// +private static void AssertAccepted(Our.IClass instance) +{{ +{I}var errors = Our.Verification.Verify(instance) +{II}.Where(error => error.Cause.Contains("safe integer range")) +{II}.Select( +{III}error => $"{{Reporting.GenerateJsonPath(error.PathSegments)}}: {{error.Cause}}") +{II}.ToList(); +{I}Assert.IsEmpty(errors); + +{I}Assert.DoesNotThrow( +{II}() => Our.Jsonization.Serialize.ToJsonObject(instance)); + +{I}Assert.DoesNotThrow( +{II}() => SerializeToXml(instance)); +}}""" + ), + ] + + numeric_places = [ + numeric_place + for numeric_place in intermediate.numeric_places(symbol_table) + if numeric_place.a_type is intermediate.PrimitiveType.INT + ] + + if len(numeric_places) > 0: + blocks.append( + Stripped( + f"""\ +private static Our.IClass DeserializeFromXml( +{I}string text, +{I}System.Func fromXml) +{{ +{I}using var stringReader = new System.IO.StringReader(text); +{I}using var xmlReader = System.Xml.XmlReader.Create(stringReader); +{I}xmlReader.MoveToContent(); +{I}return fromXml(xmlReader); +}}""" + ) + ) + + blocks.append( + Stripped( + f"""\ +/// +/// Assert that the de-serialization from JSON and from XML accepts +/// , which holds the bound +/// of the safe integer range, but refuses it +/// once the bound is moved by one out of the range. +/// +private static void AssertDeserializationChecksTheRange( +{I}Our.IClass instance, +{I}long safeValue, +{I}System.Func fromJson, +{I}System.Func fromXml) +{{ +{I}long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; +{I}string expectedCause = +{II}$"Expected an integer, but got {{unsafeValue}}, " + +{II}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + +{I}string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); +{I}Assert.IsTrue(jsonText.Contains("9007199254740991")); +{I}Assert.DoesNotThrow( +{II}() => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + +{I}var jsonException = Assert.Throws( +{II}() => fromJson( +{III}Nodes.JsonNode.Parse( +{IIII}jsonText.Replace("9007199254740991", "9007199254740992"))!)); +{I}Assert.AreEqual(expectedCause, jsonException!.Cause); + +{I}string xmlText = SerializeToXml(instance); +{I}Assert.IsTrue(xmlText.Contains("9007199254740991")); +{I}Assert.DoesNotThrow( +{II}() => DeserializeFromXml(xmlText, fromXml)); + +{I}var xmlException = Assert.Throws( +{II}() => DeserializeFromXml( +{III}xmlText.Replace("9007199254740991", "9007199254740992"), +{III}fromXml)); +{I}Assert.AreEqual(expectedCause, xmlException!.Cause); +}}""" + ) + ) + + for numeric_place in numeric_places: + cls_name = csharp_naming.class_name(numeric_place.cls.name) + cls_name_json = naming.json_model_type(numeric_place.cls.name) + + mutation = csharp_tests_jsonization.generate_numeric_place_mutation( + numeric_place + ) + + expected_json_path = ( + numeric_place.prop.json_name + if numeric_place.index is None + else f"{numeric_place.prop.json_name}[{numeric_place.index}]" + ) + + test_name_prefix = f"Test_{numeric_place.cls.name}_{numeric_place.prop.name}" + + test_name = csharp_naming.method_name( + Identifier(f"{test_name_prefix}_outside_the_safe_range") + ) + blocks.append( + Stripped( + f"""\ +[Test] +public void {test_name}() +{{ +{I}foreach (var value in new long[] {{ 9007199254740992L, -9007199254740992L }}) +{I}{{ +{II}var instance = Our.Jsonization.Deserialize.{cls_name}From( +{III}LoadTheFirstExpected({csharp_common.string_literal(cls_name_json)})); + +{II}{indent_but_first_line(mutation, II)} + +{II}AssertRefused( +{III}instance, +{III}$"Expected an integer, but got {{value}}, " + +{III}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", +{III}{csharp_common.string_literal(expected_json_path)}); +{I}}} +}} // public void {test_name}""" + ) + ) + + test_name = csharp_naming.method_name( + Identifier(f"{test_name_prefix}_at_the_bounds_of_the_safe_range") + ) + blocks.append( + Stripped( + f"""\ +[Test] +public void {test_name}() +{{ +{I}foreach (var value in new long[] {{ 9007199254740991L, -9007199254740991L }}) +{I}{{ +{II}var instance = Our.Jsonization.Deserialize.{cls_name}From( +{III}LoadTheFirstExpected({csharp_common.string_literal(cls_name_json)})); + +{II}{indent_but_first_line(mutation, II)} + +{II}AssertAccepted(instance); + +{II}AssertDeserializationChecksTheRange( +{III}instance, +{III}value, +{III}Our.Jsonization.Deserialize.{cls_name}From, +{III}Our.Xmlization.Deserialize.{cls_name}From); +{I}}} +}} // public void {test_name}""" + ) + ) + + # NOTE (mristin): + # We put the integer into an array or an object, as an integer nested in + # a JSON-able value has to be found at any depth. + for cls in symbol_table.concrete_classes: + for prop in cls.properties: + type_anno = intermediate.beneath_optional(prop.type_annotation) + + if isinstance( + type_anno, + ( + intermediate.JsonValueTypeAnnotation, + intermediate.JsonArrayTypeAnnotation, + ), + ): + construction = "new Nodes.JsonArray(Nodes.JsonValue.Create(value))" + expected_json_path = f"{prop.json_name}[0]" + + elif isinstance(type_anno, intermediate.JsonObjectTypeAnnotation): + construction = ( + 'new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }' + ) + expected_json_path = f'{prop.json_name}["key"]' + + else: + continue + + cls_name = csharp_naming.class_name(cls.name) + cls_name_json = naming.json_model_type(cls.name) + prop_name = csharp_naming.property_name(prop.name) + + test_name_prefix = f"Test_{cls.name}_{prop.name}" + + test_name = csharp_naming.method_name( + Identifier(f"{test_name_prefix}_with_an_integer_outside_the_safe_range") + ) + blocks.append( + Stripped( + f"""\ +[Test] +public void {test_name}() +{{ +{I}foreach (var value in new long[] {{ 9007199254740992L, -9007199254740992L }}) +{I}{{ +{II}var instance = Our.Jsonization.Deserialize.{cls_name}From( +{III}LoadTheFirstExpected({csharp_common.string_literal(cls_name_json)})); + +{II}instance.{prop_name} = {construction}; + +{II}AssertRefused( +{III}instance, +{III}$"Expected a JSON-able value, but got the integer {{value}}, " + +{III}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", +{III}{csharp_common.string_literal(expected_json_path)}); +{I}}} +}} // public void {test_name}""" + ) + ) + + test_name = csharp_naming.method_name( + Identifier( + f"{test_name_prefix}_with_integers_at_the_bounds_of_the_safe_range" + ) + ) + blocks.append( + Stripped( + f"""\ +[Test] +public void {test_name}() +{{ +{I}foreach (var value in new long[] {{ 9007199254740991L, -9007199254740991L }}) +{I}{{ +{II}var instance = Our.Jsonization.Deserialize.{cls_name}From( +{III}LoadTheFirstExpected({csharp_common.string_literal(cls_name_json)})); + +{II}instance.{prop_name} = {construction}; + +{II}AssertAccepted(instance); +{I}}} +}} // public void {test_name}""" + ) + ) + + if intermediate_uses.json_types(symbol_table): + blocks.append( + Stripped( + f"""\ +[Test] +public void Test_JsonValueFrom_checks_the_safe_integer_range() +{{ +{I}foreach (var value in new long[] {{ 9007199254740992L, -9007199254740992L }}) +{I}{{ +{II}var exception = Assert.Throws( +{III}() => Our.Jsonization.Deserialize.JsonValueFrom( +{IIII}new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); +{II}Assert.AreEqual( +{III}$"Expected a JSON-able value, but got the integer {{value}}, " + +{III}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", +{III}exception!.Cause); +{II}Assert.AreEqual("[0]", exception!.Path); +{I}}} + +{I}foreach (var value in new long[] {{ 9007199254740991L, -9007199254740991L }}) +{I}{{ +{II}Assert.DoesNotThrow( +{III}() => Our.Jsonization.Deserialize.JsonValueFrom( +{IIII}new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); +{I}}} +}} // public void Test_JsonValueFrom_checks_the_safe_integer_range""" + ) + ) + + blocks_joined = "\n\n".join(blocks) + + return f"""\ +{csharp_common.WARNING} + +using Our = {namespace}; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace {namespace}.Tests +{{ +{I}public class TestSafeIntegers +{I}{{ +{II}{indent_but_first_line(blocks_joined, II)} +{I}}} // class TestSafeIntegers +}} // namespace {namespace}.Tests + +{csharp_common.WARNING} +""" + + +assert generate.__doc__ is not None +assert generate.__doc__.strip().startswith(__doc__.strip()) diff --git a/aas_core_codegen/csharp/tests/_generate_test_xml_rpc.py b/aas_core_codegen/csharp/tests/_generate_test_xml_rpc.py index 41067b233..faf1f9fdc 100644 --- a/aas_core_codegen/csharp/tests/_generate_test_xml_rpc.py +++ b/aas_core_codegen/csharp/tests/_generate_test_xml_rpc.py @@ -441,6 +441,48 @@ def generate(namespace: csharp_common.NamespaceIdentifier) -> str: Stripped( f"""\ [Test] +public void Test_serialize_rejects_an_integer_outside_the_safe_range() +{{ +{I}foreach (long value in new[] {{ 9007199254740992L, -9007199254740992L }}) +{I}{{ +{II}var exception = Assert.Throws( +{III}() => SerializeToString( +{IIII}new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); +{II}Assert.AreEqual( +{III}$"Expected a JSON-able value, but got the integer {{value}}, " + +{III}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", +{III}exception!.Cause); +{II}Assert.AreEqual("[0]", exception!.Path); +{I}}} +}}""" + ), + Stripped( + f"""\ +[Test] +public void Test_round_trip_an_integer_at_the_bounds_of_the_safe_range() +{{ +{I}foreach (long value in new[] {{ 9007199254740991L, -9007199254740991L }}) +{I}{{ +{II}string text = SerializeToString(Nodes.JsonValue.Create(value)); + +{II}Nodes.JsonNode? roundTripped = DeserializeFromString( +{III}text, out Our.Reporting.Error? error); + +{II}Assert.IsNull(error); +{II}Assert.IsNotNull(roundTripped); + +{II}// NOTE (mristin): +{II}// XML-RPC carries every JSON number in a , which represents +{II}// a safe integer exactly. +{II}bool ok = roundTripped!.AsValue().TryGetValue(out double gotValue); +{II}Assert.IsTrue(ok); +{II}Assert.AreEqual((double)value, gotValue); +{I}}} +}}""" + ), + Stripped( + f"""\ +[Test] public void Test_deserialize_rejects_non_finite_number() {{ {I}// NOTE (mristin): diff --git a/aas_core_codegen/golang/lib/_generate_common.py b/aas_core_codegen/golang/lib/_generate_common.py index b493403a9..420187b22 100644 --- a/aas_core_codegen/golang/lib/_generate_common.py +++ b/aas_core_codegen/golang/lib/_generate_common.py @@ -6,6 +6,7 @@ from icontract import ensure from aas_core_codegen import intermediate +from aas_core_codegen.intermediate import uses as intermediate_uses from aas_core_codegen.common import ( Stripped, indent_but_first_line, @@ -23,7 +24,6 @@ ) from aas_core_codegen.intermediate import ( type_inference as intermediate_type_inference, - uses as intermediate_uses, ) @@ -258,7 +258,12 @@ def _generate_tuple_struct(arity: int) -> Stripped: // The meta-model calls `int` on strings, and this is its transpilation. We accept // only an optional sign followed by the ASCII digits, and only the safe integers, // i.e., the integers within -(2^53 - 1) and 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 an int64 could hold larger integers. +// TypeScript represents the integers as 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. // // Panics if text is not a safe integer. The meta-model is expected to check // the text before it calls `int`, so a panic signals a bug in the meta-model. @@ -293,8 +298,9 @@ def _generate_tuple_struct(arity: int) -> Stripped: {II}value = value*10 + int64(digit-'0') -{II}// NOTE: This is 2^53 - 1. -{II}if value > 9007199254740991 {{ +{II}// NOTE: The value is never negative here, so only the upper bound +{II}// matters. We check it on every digit so that the value never overflows. +{II}if !IsSafeInteger(value) {{ {III}panic( {IIII}fmt.Sprintf( {IIIII}"Expected a safe integer, but got a text out of its range: %q", @@ -331,10 +337,17 @@ def generate( uses_slicing = intermediate_uses.string_slicing(type_inference) uses_find = intermediate_uses.string_find_call(type_inference) + uses_json_types = intermediate_uses.json_types(symbol_table) + uses_integers = intermediate_uses.integers(symbol_table) + uses_int_call = intermediate_uses.int_call(symbol_table) + import_lines: List[str] = [] - if intermediate_uses.int_call(symbol_table): + if uses_int_call or uses_json_types or uses_integers: import_lines.append(f'{I}"fmt"') + if uses_json_types: + import_lines.append(f'{I}"math"') + if intermediate_uses.set_properties(symbol_table): import_lines.append(f'{I}"sort"') @@ -647,7 +660,7 @@ def generate( if intermediate_uses.abs_call(symbol_table): blocks.append(ABS_INT64) - if intermediate_uses.int_call(symbol_table): + if uses_int_call: blocks.append(PARSE_SAFE_INT) if intermediate_uses.sets(symbol_table): @@ -687,6 +700,95 @@ def generate( if intermediate_uses.set_properties(symbol_table): blocks.extend(SORTED_KEYS) + # NOTE (mristin): + # The de-serialization, the serialization and the verification of + # the integers and of the JSON-able values all check the range with + # the predicates below, so that a verified instance is guaranteed to + # serialize. The messages are kept apart from the checks. Parsing a text + # as an integer checks the range with the same predicate. + if intermediate_uses.safe_integer_checks(symbol_table): + blocks.append( + Stripped( + f"""\ +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool {{ +{I}return -9007199254740991 <= value && value <= 9007199254740991 +}}""" + ) + ) + + if uses_integers: + blocks.extend( + [ + Stripped( + f"""\ +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool {{ +{I}return -9007199254740991 <= value && value <= 9007199254740991 +}}""" + ), + Stripped( + f"""\ +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{{}}) string {{ +{I}return fmt.Sprintf( +{II}"Expected an integer, but got %v, "+ +{II}"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", +{II}value, +{I}) +}}""" + ), + ] + ) + + if uses_json_types: + blocks.extend( + [ + Stripped( + f"""\ +// Describe why the integer `value` is not JSON-able, as it is not a safe +// integer, see [IsSafeInteger]. +func NotJsonAbleIntegerMessage(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"""\ +// Check that `value`, a number of a JSON-able value, is finite. +// +// JSON knows neither an infinity nor a not-a-number. +// +// Return the cause why `value` is not JSON-able, or an empty string if it is. +func CheckJsonAbleFloat64(value float64) string {{ +{I}if math.IsInf(value, 0) || math.IsNaN(value) {{ +{II}return fmt.Sprintf( +{III}"Expected a JSON-able value, but got the number %v, "+ +{III}"which is neither finite nor representable in JSON", +{III}value, +{II}) +{I}}} + +{I}return "" +}}""" + ), + ] + ) + blocks.append(golang_common.WARNING) writer = io.StringIO() diff --git a/aas_core_codegen/golang/lib/_generate_jsonization.py b/aas_core_codegen/golang/lib/_generate_jsonization.py index c5c3c7a3b..7b10b113a 100644 --- a/aas_core_codegen/golang/lib/_generate_jsonization.py +++ b/aas_core_codegen/golang/lib/_generate_jsonization.py @@ -26,7 +26,6 @@ INDENT3 as III, INDENT4 as IIII, INDENT5 as IIIII, - INDENT6 as IIIIII, ) from aas_core_codegen.intermediate import uses as intermediate_uses @@ -69,62 +68,42 @@ def _generate_int64_from_jsonable() -> Stripped: return Stripped( f"""\ // Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. func int64FromJsonable( {I}jsonable interface{{}}, ) (result int64, err error) {{ -{I}if jsonable == nil {{ -{II}err = newDeserializationError( -{III}"Expected an integer number, but got null", -{II}) -{II}return -{I}}} - {I}f, ok := jsonable.(float64) {I}if !ok {{ {II}err = newDeserializationError( {III}fmt.Sprintf( -{IIII}"Expected an integer number, but got %T", +{IIII}"Expected an integer, but got: %T", {IIII}jsonable, {III}), {II}) {II}return {I}}} -{I}if math.IsNaN(f) {{ -{II}err = newDeserializationError( -{III}"Expected an integer number, but got a NaN", -{II}) -{II}return -{I}}} - -{I}if math.IsInf(f, 0) {{ -{II}err = newDeserializationError( -{III}"Expected an integer number, but got an infinity", -{II}) -{II}return -{I}}} - -{I}if f != math.Trunc(f) {{ +{I}// NOTE (mristin): +{I}// A not-a-number is never equal to its truncation. +{I}if math.IsInf(f, 0) || f != math.Trunc(f) {{ {II}err = newDeserializationError( {III}fmt.Sprintf( -{IIII}"Expected an integer number, but got a non-integer: %v", +{IIII}"Expected an integer, but got %v, which is not a whole number", {IIII}f, {III}), {II}) {II}return {I}}} -{I}result = int64(f) -{I}if f != float64(result) {{ -{II}err = newDeserializationError( -{III}fmt.Sprintf( -{IIII}"Expected an integer number fitting into int64, but got: %v", -{IIII}jsonable, -{III}), -{II}) +{I}if !ourcommon.IsSafeIntegerFloat64(f) {{ +{II}err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) {II}return {I}}} +{I}result = int64(f) {I}return }}""" ) @@ -1099,13 +1078,13 @@ def _determine_parse_function_for_atomic_value( assert_never(our_type) elif isinstance(type_annotation, intermediate.JsonValueTypeAnnotation): - function_name = "jsonValueFromJsonable" + function_name = "JsonValueFromJsonable" elif isinstance(type_annotation, intermediate.JsonArrayTypeAnnotation): - function_name = "jsonArrayFromJsonable" + function_name = "JsonArrayFromJsonable" elif isinstance(type_annotation, intermediate.JsonObjectTypeAnnotation): - function_name = "jsonObjectFromJsonable" + function_name = "JsonObjectFromJsonable" else: # noinspection PyTypeChecker @@ -1633,8 +1612,9 @@ def _generate_int64_to_jsonable() -> Stripped: """Generate the function to encode an ``int64`` to a JSON-able.""" return Stripped( f"""\ -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{{}}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -1643,17 +1623,12 @@ def _generate_int64_to_jsonable() -> Stripped: func int64ToJsonable( {I}that int64, ) (result interface{{}}, err error) {{ -{I}if that > 9007199254740991 || that < -9007199254740991 {{ -{II}err = newSerializationError( -{III}fmt.Sprintf( -{IIII}"64-bit integer can not be represented as 64-bit float in JSON: %v", -{IIII}that, -{III}), -{II}) +{I}if !ourcommon.IsSafeInteger(that) {{ +{II}err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) {II}return {I}}} -{I}result = float64(that); +{I}result = float64(that) {I}return }}""" ) @@ -1781,13 +1756,13 @@ def _generate_json_able_as_jsonable_interface() -> Stripped: f"""\ // Serialize the JSON-able `item` as a JSON-able value. // -// ``jsonValueToJsonable`` takes an ``ourtypes.JsonValue``, so a *function* +// ``JsonValueToJsonable`` takes an ``ourtypes.JsonValue``, so a *function* // which takes an ``ourtypes.JsonArray`` or an ``ourtypes.JsonObject`` is // a different type altogether, even though either value is assignable to // an ``ourtypes.JsonValue``. This wrapper gives the one signature which // ``serializeArray``/``serializeTupleN`` expect for every shape. func jsonAbleToJsonable[T any](item T) (interface{{}}, error) {{ -{I}return jsonValueToJsonable(item) +{I}return JsonValueToJsonable(item) }}""" ) @@ -1895,18 +1870,28 @@ def _generate_json_able_helpers( // a not-a-number are rejected at any depth, as JSON can not represent them // at all. // +// An int or an int64, as it might come from a value put together +// programmatically, is accepted as well, and parsed as a float64, provided +// that it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// // The result is a new structure, and never `jsonable` itself, so that // the parsed instance does not alias the document it came from. // -// The path of an error is relative to `jsonable`, and the caller is expected -// to prepend the way to it. -func jsonValueFromJsonable( +// The path of an error is relative to `jsonable`. +func JsonValueFromJsonable( {I}jsonable interface{{}}, ) (result ourtypes.JsonValue, err error) {{ {I}if jsonable == nil {{ {II}return {I}}} +{I}// NOTE (mristin): +{I}// An int is widened to an int64 first, so that a single case below +{I}// handles both. +{I}if integer, ok := jsonable.(int); ok {{ +{II}jsonable = int64(integer) +{I}}} + {I}switch casted := jsonable.(type) {{ {II}case bool: {III}result = casted @@ -1916,19 +1901,23 @@ def _generate_json_able_helpers( {III}result = casted {III}return +{II}case int64: +{III}if !ourcommon.IsSafeInteger(casted) {{ +{IIII}err = newDeserializationError( +{IIIII}ourcommon.NotJsonAbleIntegerMessage(casted), +{IIII}) +{IIII}return +{III}}} +{III}result = float64(casted) +{III}return + {II}case float64: {III}// NOTE (mristin): {III}// JSON knows neither an infinity nor a not-a-number. encoding/json {III}// never gives us one, but `jsonable` may well have been put together {III}// programmatically. -{III}if math.IsInf(casted, 0) || math.IsNaN(casted) {{ -{IIII}err = newDeserializationError( -{IIIII}fmt.Sprintf( -{IIIIII}"Expected a JSON-able value, but got the number %v, "+ -{IIIIII}"which is neither finite nor representable in JSON", -{IIIIII}casted, -{IIIII}), -{IIII}) +{III}if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" {{ +{IIII}err = newDeserializationError(cause) {IIII}return {III}}} {III}result = casted @@ -1938,7 +1927,7 @@ def _generate_json_able_helpers( {III}items := make(ourtypes.JsonArray, 0, len(casted)) {III}for i, item := range casted {{ {IIII}var parsed ourtypes.JsonValue -{IIII}parsed, err = jsonValueFromJsonable(item) +{IIII}parsed, err = JsonValueFromJsonable(item) {IIII}if err != nil {{ {IIIII}mustDeserializationError(err).prependIndex(i) {IIIII}return @@ -1952,7 +1941,7 @@ def _generate_json_able_helpers( {III}members := make(ourtypes.JsonObject, len(casted)) {III}for key, value := range casted {{ {IIII}var parsed ourtypes.JsonValue -{IIII}parsed, err = jsonValueFromJsonable(value) +{IIII}parsed, err = JsonValueFromJsonable(value) {IIII}if err != nil {{ {IIIII}mustDeserializationError(err).prependKey(key) {IIIII}return @@ -1965,8 +1954,8 @@ def _generate_json_able_helpers( {II}default: {III}err = newDeserializationError( {IIII}fmt.Sprintf( -{IIIII}"Expected a JSON-able value (a bool, a float64, a string, "+ -{IIIII}"an array or an object), but got: %T", +{IIIII}"Expected a JSON-able value (a bool, a float64, an int, an int64, "+ +{IIIII}"a string, an array or an object), but got: %T", {IIIII}jsonable, {IIII}), {III}) @@ -1977,7 +1966,7 @@ def _generate_json_able_helpers( Stripped( f"""\ // Parse `jsonable` as a JSON-able array. -func jsonArrayFromJsonable( +func JsonArrayFromJsonable( {I}jsonable interface{{}}, ) (result ourtypes.JsonArray, err error) {{ {I}if _, ok := jsonable.([]interface{{}}); !ok {{ @@ -1988,7 +1977,7 @@ def _generate_json_able_helpers( {I}}} {I}var value ourtypes.JsonValue -{I}value, err = jsonValueFromJsonable(jsonable) +{I}value, err = JsonValueFromJsonable(jsonable) {I}if err != nil {{ {II}return {I}}} @@ -2000,7 +1989,7 @@ def _generate_json_able_helpers( Stripped( f"""\ // Parse `jsonable` as a JSON-able object. -func jsonObjectFromJsonable( +func JsonObjectFromJsonable( {I}jsonable interface{{}}, ) (result ourtypes.JsonObject, err error) {{ {I}if _, ok := jsonable.(map[string]interface{{}}); !ok {{ @@ -2011,7 +2000,7 @@ def _generate_json_able_helpers( {I}}} {I}var value ourtypes.JsonValue -{I}value, err = jsonValueFromJsonable(jsonable) +{I}value, err = JsonValueFromJsonable(jsonable) {I}if err != nil {{ {II}return {I}}} @@ -2029,15 +2018,25 @@ def _generate_json_able_helpers( // represent. It is refused here, at any depth. A nil is written as // a JSON null. // +// An int or an int64 is accepted as well, and written as a float64, provided +// that it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// // The result is a new structure, and never `value` itself, so that // the serialized document does not alias the instance it came from. -func jsonValueToJsonable( +func JsonValueToJsonable( {I}value ourtypes.JsonValue, ) (result interface{{}}, err error) {{ {I}if value == nil {{ {II}return {I}}} +{I}// NOTE (mristin): +{I}// An int is widened to an int64 first, so that a single case below +{I}// handles both. +{I}if integer, ok := value.(int); ok {{ +{II}value = int64(integer) +{I}}} + {I}switch casted := value.(type) {{ {II}case bool: {III}result = casted @@ -2047,17 +2046,21 @@ def _generate_json_able_helpers( {III}result = casted {III}return -{II}case float64: -{III}if math.IsInf(casted, 0) || math.IsNaN(casted) {{ +{II}case int64: +{III}if !ourcommon.IsSafeInteger(casted) {{ {IIII}err = newSerializationError( -{IIIII}fmt.Sprintf( -{IIIIII}"Expected a JSON-able value, but got the number %v, which "+ -{IIIIII}"is neither finite nor representable in JSON", -{IIIIII}casted, -{IIIII}), +{IIIII}ourcommon.NotJsonAbleIntegerMessage(casted), {IIII}) {IIII}return {III}}} +{III}result = float64(casted) +{III}return + +{II}case float64: +{III}if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" {{ +{IIII}err = newSerializationError(cause) +{IIII}return +{III}}} {III}result = casted {III}return @@ -2065,7 +2068,7 @@ def _generate_json_able_helpers( {III}items := make([]interface{{}}, 0, len(casted)) {III}for i, item := range casted {{ {IIII}var serialized interface{{}} -{IIII}serialized, err = jsonValueToJsonable(item) +{IIII}serialized, err = JsonValueToJsonable(item) {IIII}if err != nil {{ {IIIII}mustSerializationError(err).prependIndex(i) {IIIII}return @@ -2079,7 +2082,7 @@ def _generate_json_able_helpers( {III}members := make(map[string]interface{{}}, len(casted)) {III}for key, item := range casted {{ {IIII}var serialized interface{{}} -{IIII}serialized, err = jsonValueToJsonable(item) +{IIII}serialized, err = JsonValueToJsonable(item) {IIII}if err != nil {{ {IIIII}mustSerializationError(err).prependKey(key) {IIIII}return @@ -2092,8 +2095,8 @@ def _generate_json_able_helpers( {II}default: {III}err = 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}) @@ -2159,7 +2162,7 @@ def _item_serializer_function( return Stripped(f"directToJsonable[{item_type}]") # NOTE (mristin): - # A JSON-able item is checked and copied by ``jsonValueToJsonable``, which + # A JSON-able item is checked and copied by ``JsonValueToJsonable``, which # already has the expected signature, so it needs no wrapper. if isinstance( type_annotation, @@ -2539,7 +2542,7 @@ def _determine_serialization_of_atomic_value( intermediate.JsonObjectTypeAnnotation, ), ): - return Stripped("jsonValueToJsonable"), Stripped(access_expression) + return Stripped("JsonValueToJsonable"), Stripped(access_expression) # NOTE (mristin): # The following types are handled as primitive values in Golang, so we have @@ -2979,7 +2982,6 @@ def generate( }}""" ), _generate_bool_from_jsonable(), - _generate_int64_from_jsonable(), _generate_float64_from_jsonable(), _generate_string_from_jsonable(), _generate_bytes_from_jsonable(), @@ -2990,6 +2992,15 @@ def generate( _generate_parse_array(), ] + # NOTE (mristin): + # The integers are de/serialized with ``ourcommon.IsSafeInteger`` and + # ``ourcommon.IsSafeIntegerFloat64``, which are generated only if + # the instances can hold any integers. + uses_integers = intermediate_uses.integers(symbol_table) + + if uses_integers: + blocks.append(_generate_int64_from_jsonable()) + if len(symbol_table.enumerations) > 0: blocks.append(_generate_not_an_enum_text_error()) blocks.append(_generate_unexpected_enum_literal_error()) @@ -3148,7 +3159,9 @@ def generate( ] ) - blocks.append(_generate_int64_to_jsonable()) + if uses_integers: + blocks.append(_generate_int64_to_jsonable()) + blocks.append(_generate_float64_to_jsonable()) blocks.append(_generate_bytes_to_jsonable()) blocks.append(_generate_serialize_array()) diff --git a/aas_core_codegen/golang/lib/_generate_verification.py b/aas_core_codegen/golang/lib/_generate_verification.py index b2ccc3381..c8dc89095 100644 --- a/aas_core_codegen/golang/lib/_generate_verification.py +++ b/aas_core_codegen/golang/lib/_generate_verification.py @@ -12,6 +12,7 @@ Union, Set, Dict, + Final, ) from icontract import ensure, require @@ -864,7 +865,9 @@ def _needs_verification(type_annotation: intermediate.TypeAnnotationUnion) -> bo Check whether a value of ``type_annotation`` has anything to verify at any depth. In Go, we verify all our types, including the enumerations, as any string - can be converted to an enumeration, and all the JSON-able values. + can be converted to an enumeration, all the JSON-able values, and all + the integers, as an ``int64`` might lie outside the safe integer range + of JSON. """ return any( isinstance( @@ -876,6 +879,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 ) @@ -1005,6 +1012,12 @@ def _generate_verify_into( else: assert_never(our_type) + elif ( + isinstance(type_anno, intermediate.PrimitiveTypeAnnotation) + and type_anno.a_type is intermediate.PrimitiveType.INT + ): + function = "verifyInt64" + elif isinstance(type_anno, intermediate.JsonValueTypeAnnotation): function = "verifyJsonValue" @@ -1162,8 +1175,9 @@ def _generate_verify_json_value( A JSON-able value is, recursively, exactly as JSON itself is defined, but ``JsonValue`` is an ``any``, so it rules out none of the two ways of not - being one: a non-finite number and a value of some other type altogether. - Each is reported here, at any depth. A ``nil`` is a JSON null, and hence + being one: a number which is not JSON-able (see ``IsSafeInteger`` and + ``CheckJsonAbleFloat64`` in the common package) and a value of some other + type altogether. Each is reported here, at any depth. A ``nil`` is a JSON null, and hence a JSON-able value. The path *within* a JSON-able value is a path of the error like any other: @@ -1202,6 +1216,13 @@ def _generate_verify_json_value( {II}return false {I}}} +{I}// NOTE (mristin): +{I}// An int is widened to an int64 first, so that a single case below +{I}// handles both, as in the de/serialization. +{I}if integer, ok := value.(int); ok {{ +{II}value = int64(integer) +{I}}} + {I}switch casted := value.(type) {{ {II}case bool: {III}return false @@ -1209,23 +1230,20 @@ def _generate_verify_json_value( {II}case string: {III}return false -{II}case float64: -{III}// NOTE (mristin): -{III}// JSON knows neither an infinity nor a not-a-number, so neither is -{III}// a JSON-able value, even though a float64 holds either. -{III}if math.IsInf(casted, 0) || math.IsNaN(casted) {{ +{II}case int64: +{III}if !ourcommon.IsSafeInteger(casted) {{ {IIII}return onError( -{IIIII}newVerificationError( -{IIIIII}fmt.Sprintf( -{IIIIIII}"Expected a JSON-able value, but got the number %v, "+ -{IIIIIII}"which is neither finite nor representable in JSON", -{IIIIIII}casted, -{IIIIII}), -{IIIII}), +{IIIII}newVerificationError(ourcommon.NotJsonAbleIntegerMessage(casted)), {IIII}) {III}}} {III}return false +{II}case float64: +{III}if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" {{ +{IIII}return onError(newVerificationError(cause)) +{III}}} +{III}return false + {II}case ourtypes.JsonArray: {III}for i, item := range casted {{ {IIII}abort = verifyJsonValue( @@ -1270,8 +1288,8 @@ def _generate_verify_json_value( {III}return onError( {IIII}newVerificationError( {IIIII}fmt.Sprintf( -{IIIIII}"Expected a JSON-able value (a bool, a float64, a string, "+ -{IIIIII}"a JsonArray or a JsonObject), but got: %T", +{IIIIII}"Expected a JSON-able value (a bool, a float64, an int, an int64, "+ +{IIIIII}"a string, a JsonArray or a JsonObject), but got: %T", {IIIIII}value, {IIIII}), {IIII}), @@ -1630,6 +1648,36 @@ def _generate_verify_enumeration( ) +#: Report an integer ``that`` outside the safe integer range of JSON, as +#: the statements of a verification function +_SAFE_INTEGER_CHECK: Final[Stripped] = Stripped( + f"""\ +if !ourcommon.IsSafeInteger(that) {{ +{I}abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) +{I}if abort {{ +{II}return +{I}}} +}}""" +) + + +def _generate_verify_int64() -> Stripped: + """Generate the function verifying an ``int64`` as an item or a property.""" + return Stripped( + f"""\ +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( +{I}that int64, +{I}onError func(*VerificationError) bool, +) (abort bool) {{ +{I}{indent_but_first_line(_SAFE_INTEGER_CHECK, I)} + +{I}return +}}""" + ) + + def _generate_verify_constrained_primitive( constrained_primitive: intermediate.ConstrainedPrimitive, symbol_table: intermediate.SymbolTable, @@ -1663,6 +1711,12 @@ def _generate_verify_constrained_primitive( if len(errors) > 0: return None, errors + # NOTE (mristin): + # An integer outside the safe range can not be serialized to JSON, so we + # report it before the invariants, with the same check as the serialization. + if constrained_primitive.constrainee is intermediate.PrimitiveType.INT: + blocks.insert(0, _SAFE_INTEGER_CHECK) + if len(blocks) == 0: blocks.append(Stripped("// There is no verification specified.")) @@ -1790,6 +1844,7 @@ def generate( # collide with the unexported names that we generate in the same package. origin_by_unexported_name: MutableMapping[str, str] = { "newVerificationError": "our helper function to create verification errors", + "verifyInt64": "our helper function to verify integers", "verifyJsonValue": "our helper function to verify JSON-able values", "verifyJsonArray": "our helper function to verify JSON-able arrays", "verifyJsonObject": "our helper function to verify JSON-able objects", @@ -1880,6 +1935,9 @@ def generate( blocks.extend(_generate_verify_json_value(symbol_table=symbol_table)) + if intermediate_uses.integers(symbol_table): + blocks.append(_generate_verify_int64()) + for cls in symbol_table.concrete_classes: block, underlying_errors = _generate_verify_class( cls=cls, symbol_table=symbol_table, type_inference=type_inference diff --git a/aas_core_codegen/golang/lib/_generate_xml_common.py b/aas_core_codegen/golang/lib/_generate_xml_common.py index 584590fd1..effd53f89 100644 --- a/aas_core_codegen/golang/lib/_generate_xml_common.py +++ b/aas_core_codegen/golang/lib/_generate_xml_common.py @@ -491,6 +491,9 @@ def _generate_read_text_as_long() -> Stripped: f"""\ // Consume the text tokens (char data) as a representation of a `xs:long`. // +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// // Any comment tokens are skipped. // // The resulting `next` token points to the first token which is neither text @@ -520,6 +523,12 @@ def _generate_read_text_as_long() -> Stripped: {II}return {I}}} +{I}if !ourcommon.IsSafeInteger(value) {{ +{II}err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) +{II}value = 0 +{II}return +{I}}} + {I}return }}""" ) @@ -1173,11 +1182,20 @@ def _generate_write_as_text_long() -> Stripped: f"""\ // Write the `value` as a `xs:long` in a text element. // +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// // Do not flush. func WriteAsText_long( {I}encoder *xml.Encoder, {I}value int64, ) (err error) {{ +{I}if !ourcommon.IsSafeInteger(value) {{ +{II}err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) +{II}return +{I}}} + {I}text := strconv.FormatInt(value, 10) {I}err = WriteText(encoder, text) {I}return @@ -1434,7 +1452,6 @@ def generate(symbol_table: intermediate.SymbolTable, repo_url: Stripped) -> str: _generate_skip_empty_text_whitespace_and_comments(), _generate_read_text(), _generate_read_text_as_bool(), - _generate_read_text_as_long(), *_generate_is_valid_xs_double(), _generate_read_text_as_double(), _generate_read_text_as_bytes(), @@ -1448,7 +1465,6 @@ def generate(symbol_table: intermediate.SymbolTable, repo_url: Stripped) -> str: _generate_write_end_element(), _generate_write_text(), _generate_write_as_text_bool(), - _generate_write_as_text_long(), _generate_write_as_text_double(), _generate_write_as_text_string(), _generate_write_as_text_bytes(), @@ -1456,6 +1472,12 @@ def generate(symbol_table: intermediate.SymbolTable, repo_url: Stripped) -> str: ] ) + # NOTE (mristin): + # The integers are checked with ``ourcommon.IsSafeInteger``, which is + # generated only if the instances can hold any integers. + if intermediate_uses.integers(symbol_table): + blocks.extend([_generate_read_text_as_long(), _generate_write_as_text_long()]) + if uses_json_types: blocks.extend( [ diff --git a/aas_core_codegen/golang/lib/_generate_xml_rpc.py b/aas_core_codegen/golang/lib/_generate_xml_rpc.py index 3d23eca6c..18885c69b 100644 --- a/aas_core_codegen/golang/lib/_generate_xml_rpc.py +++ b/aas_core_codegen/golang/lib/_generate_xml_rpc.py @@ -545,6 +545,13 @@ def _generate_writers() -> List[Stripped]: {II}return {I}}} +{I}// NOTE (mristin): +{I}// An int is widened to an int64 first, so that a single case below +{I}// handles both, as in the JSON de/serialization and the verification. +{I}if integer, ok := value.(int); ok {{ +{II}value = int64(integer) +{I}}} + {I}switch casted := value.(type) {{ {II}case bool: {III}// NOTE (mristin): @@ -564,6 +571,23 @@ def _generate_writers() -> List[Stripped]: {III}) {III}return +{II}case int64: +{III}// NOTE (mristin): +{III}// An integer is written as a ``, 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, "") -{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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -13818,18 +13707,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/test/common.hpp index 0e8632bdf..d97826f9b 100644 --- a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/test/test_xmlization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/test/test_xmlization_of_concrete_classes.cpp index 924bac7f5..49037a727 100644 --- a/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/test/test_xmlization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/aas_core_meta.v3/expected_output/test/test_xmlization_of_concrete_classes.cpp @@ -2080,10 +2080,10 @@ TEST_CASE("Test the de-serialization failure on a duplicate property") { } /** - * \brief Read the first recorded example of ILevelType with the content - * of the element \p xml_name replaced by \p text. + * \brief Patch the first recorded example of ILevelType so that + * the content of the element \p xml_name is replaced by \p text. */ -std::shared_ptr ReadWith( +std::string PatchTheFirstExample( const std::string& xml_name, const std::string& text ) { @@ -2111,9 +2111,18 @@ std::shared_ptr ReadWith( original.find(our::common::Concat("")) ); - const std::string patched( - original.substr(0, start) + text + original.substr(end) - ); + return original.substr(0, start) + text + original.substr(end); +} + +/** + * \brief Read the first recorded example of ILevelType with the content + * of the element \p xml_name replaced by \p text. + */ +std::shared_ptr ReadWith( + const std::string& xml_name, + const std::string& text +) { + const std::string patched(PatchTheFirstExample(xml_name, text)); std::istringstream iss(patched); diff --git a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/include/dummy/common.hpp index aebb2cad0..0187d2b36 100644 --- a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/include/dummy/common.hpp @@ -10145,6 +10145,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + /** * \brief Give a new set of the items which are both in \p that and * in \p other. diff --git a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/include/dummy/verification.hpp index 57bd5c1ee..05f59a799 100644 --- a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/include/dummy/verification.hpp @@ -139,6 +139,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/jsonization.cpp index fa5a2d1b5..b19ff7c4e 100644 --- a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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 - ) - ); - } + int64_t value = 0; + common::optional cause; - // 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. + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -2750,32 +2734,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/verification.cpp index 067a15066..2ae3c5a68 100644 --- a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool TextIsAllowedUnlessLong( @@ -424,7 +434,8 @@ namespace { */ enum class Shape : std::uint32_t { kCode = 0, - kSomething = 1 + kSomething = 1, + kINT64 = 2 }; // enum class Shape /** @@ -684,6 +695,8 @@ bool Something_19( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kCode: { static const std::vector checks = { @@ -784,6 +797,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -796,19 +813,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -967,6 +1045,67 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + /** * \brief Iterate over the values of the children, one child after another. * @@ -1137,15 +1276,288 @@ std::unique_ptr InProperty( return common::make_unique(property, std::move(child)); } +/** + * \brief Iterate over the values of every item of a list, one item after another. + * + * The iterator over an item is built only once the iteration reaches the item. + */ +template +class EachIterator : public IIterator { + public: + /** + * Build the iterator over the values of an item + */ + typedef std::unique_ptr (*OverItem)(const T& item, bool recursive); + + EachIterator( + const std::vector* items, + OverItem over_item, + bool recursive + ) : + items_(items), + over_item_(over_item), + recursive_(recursive), + index_(0) { + // Intentionally empty. + } + + EachIterator(const EachIterator& other) : + items_(other.items_), + over_item_(other.over_item_), + recursive_(other.recursive_), + index_(other.index_), + item_(other.item_ == nullptr ? nullptr : other.item_->Clone()) { + // Intentionally empty. + } + + void Start() override { + index_ = 0; + item_ = nullptr; + SkipDoneItems(); + } + + void Next() override { + item_->Next(); + SkipDoneItems(); + } + + bool Done() const override { + return index_ >= items_->size(); + } + + const void* Value() const override { + return item_->Value(); + } + + Shape ShapeOf() const override { + return item_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(index_) + ); + item_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + const std::vector* items_; + OverItem over_item_; + bool recursive_; + + /** + * Index of the item we currently iterate over + */ + std::size_t index_; + + /** + * Iterator over the current item, built once we reached the item + */ + std::unique_ptr item_; + + /** + * Move on to the next items, and build their iterators, until one is not done. + */ + void SkipDoneItems() { + while (index_ < items_->size()) { + if (item_ == nullptr) { + item_ = over_item_((*items_)[index_], recursive_); + item_->Start(); + } + + if (!item_->Done()) { + return; + } + + item_ = nullptr; + ++index_; + } + } +}; // class EachIterator + +template +std::unique_ptr Each( + const std::vector& items, + std::unique_ptr (*over_item)(const T& item, bool recursive), + bool recursive +) { + return common::make_unique >(&items, over_item, recursive); +} + +/** + * \brief Iterate over the values of an instance, dispatched on its runtime type. + * + * Defined below, once all the classes have been covered. + */ +std::unique_ptr DispatchOnModelType( + const types::IClass& instance, + bool recursive +); + +/** + * \brief Iterate recursively over the values of an instance referenced from + * another instance. + * + * We dispatch on the runtime type of the instance only in \ref Start so that + * we descend into the instance only once the iteration reaches it. + */ +class DispatchingIterator : public IIterator { + public: + explicit DispatchingIterator( + const types::IClass* instance + ) : + instance_(instance) { + // Intentionally empty. + } + + DispatchingIterator(const DispatchingIterator& other) : + instance_(other.instance_), + child_(other.child_ == nullptr ? nullptr : other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_ = DispatchOnModelType(*instance_, true); + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + const types::IClass* instance_; + std::unique_ptr child_; +}; // class DispatchingIterator + +std::unique_ptr Over( + const types::IClass& instance, + bool recursive +) { + if (!recursive) { + // NOTE (mristin): + // In the non-recursive mode, we verify only the instance itself, but not + // the instances that it references. + return Empty(); + } + + return common::make_unique(&instance); +} + +template +std::unique_ptr ThroughPointer( + const std::shared_ptr& instance, + bool recursive +) { + return Over(*instance, recursive); +} + +using listOf_Parent = std::vector< + std::shared_ptr +>; + +std::unique_ptr Over_listOf_Parent( + const listOf_Parent& value, + bool recursive +) { + if (!recursive) { + return Empty(); + } + + return Each(value, &ThroughPointer, recursive); +} + +std::unique_ptr Over_ParentOrItem( + const types::ParentOrItem& value, + bool recursive +) { + switch (value.index()) { + case 0: + return Over(*common::get<0>(value), recursive); + case 1: + return Over(*common::get<1>(value), recursive); + default: + throw std::logic_error("Invalid variant index"); + } +} + +std::unique_ptr Over_ChildA( + const types::IChildA& that, + bool +) { + return InProperty( + iteration::Property::kAOnly, + OneByValue(that.a_only(), Shape::kINT64) + ); +} + +std::unique_ptr Over_ChildB( + const types::IChildB& that, + bool +) { + return InProperty( + iteration::Property::kBOnly, + OneByValue(that.b_only(), Shape::kINT64) + ); +} + std::unique_ptr Over_Something( const types::ISomething& that, - bool + bool recursive ) { return Chain( One(&that, Shape::kSomething), + InProperty( + iteration::Property::kNumber, + OneByValue(that.number(), Shape::kINT64) + ), InProperty( iteration::Property::kCode, One(&that.code(), Shape::kCode) + ), + InProperty( + iteration::Property::kParent, + Over(*that.parent(), recursive) + ), + InProperty( + iteration::Property::kParents, + Over_listOf_Parent(that.parents(), recursive) + ), + InProperty( + iteration::Property::kOptionalParent, + that.optional_parent().has_value() + ? Over(*(*that.optional_parent()), recursive) + : Empty() + ), + InProperty( + iteration::Property::kOptionalMember, + that.optional_member().has_value() + ? Over_ParentOrItem((*that.optional_member()), recursive) + : Empty() ) ); } @@ -1158,6 +1570,16 @@ std::unique_ptr DispatchOnModelType( bool recursive ) { switch (instance.model_type()) { + case types::ModelType::kChildA: + return Over_ChildA( + dynamic_cast(instance), + recursive + ); + case types::ModelType::kChildB: + return Over_ChildB( + dynamic_cast(instance), + recursive + ); case types::ModelType::kSomething: return Over_Something( dynamic_cast(instance), diff --git a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/xmlization.cpp index 929590dfd..23a5fc0bc 100644 --- a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1425,6 +1426,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2946,6 +2955,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/test_jsonization_of_concrete_classes.cpp index 61d903ede..0bd4e5b7f 100644 --- a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Item") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -437,16 +545,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .a_only of ChildA" + "Test the safe integer range at .a_only of ChildA" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "ChildA", @@ -456,24 +562,45 @@ TEST_CASE( instance->set_a_only(value); - AssertSerializationFailsAt( - *instance, - ".a_only" + AssertReportedEverywhere( + instance, + ".a_only", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "ChildA", + our::jsonization::ChildAFrom + ) ); + + instance->set_a_only(value); + + AssertNotReportedAnywhere(instance, ".a_only"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .b_only of ChildB" + "Test the safe integer range at .b_only of ChildB" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "ChildB", @@ -483,24 +610,45 @@ TEST_CASE( instance->set_b_only(value); - AssertSerializationFailsAt( - *instance, - ".b_only" + AssertReportedEverywhere( + instance, + ".b_only", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "ChildB", + our::jsonization::ChildBFrom + ) ); + + instance->set_b_only(value); + + AssertNotReportedAnywhere(instance, ".b_only"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .number of Something" + "Test the safe integer range at .number of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -510,10 +658,33 @@ TEST_CASE( instance->set_number(value); - AssertSerializationFailsAt( - *instance, - ".number" + AssertReportedEverywhere( + instance, + ".number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_number(value); + + AssertNotReportedAnywhere(instance, ".number"); } } diff --git a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/test_verification.cpp index 9ec97058b..06d9c678f 100644 --- a/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/annotated_assignments/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -320,5 +321,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/arithmetic/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/arithmetic/expected_output/include/dummy/common.hpp index 929567512..5cf1c32bc 100644 --- a/dev/test_data/main/cpp/expected/arithmetic/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/arithmetic/expected_output/include/dummy/common.hpp @@ -10139,6 +10139,43 @@ size_t LenStr(const std::wstring& text); */ int64_t FloorMod(int64_t dividend, int64_t divisor); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/arithmetic/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/arithmetic/expected_output/include/dummy/verification.hpp index ec57d880d..ebdc64576 100644 --- a/dev/test_data/main/cpp/expected/arithmetic/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/arithmetic/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; @@ -255,7 +267,8 @@ bool HasRemainderOneByLength( // region Verification of constrained primitives /** - * \brief Verify that the invariants hold for \p that value. + * \brief Verify that the invariants hold for \p that value, and that + * it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. * * \param that value to be verified * \return Iterable over constraint violations diff --git a/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/common.cpp index 0960c2305..d03bb28cd 100644 --- a/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/common.cpp @@ -14138,6 +14138,26 @@ int64_t FloorMod(int64_t dividend, int64_t divisor) { return remainder; } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/jsonization.cpp index d59406fdb..e411128d1 100644 --- a/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1347,32 +1331,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/verification.cpp index 02a3e646a..8bde6fb3d 100644 --- a/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/verification.cpp @@ -39,6 +39,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool HasSmallRemainder( @@ -102,7 +112,8 @@ namespace { */ enum class Shape : std::uint32_t { kEvenInt = 0, - kSomething = 1 + kSomething = 1, + kINT64 = 2 }; // enum class Shape /** @@ -265,6 +276,8 @@ bool Something_11( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kEvenInt: { static const std::vector checks = { @@ -331,6 +344,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -343,19 +360,81 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kEvenInt: + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -754,6 +833,36 @@ std::unique_ptr Over_Something( InProperty( iteration::Property::kEven, OneByValue(that.even(), Shape::kEvenInt) + ), + InProperty( + iteration::Property::kOffset, + OneByValue(that.offset(), Shape::kINT64) + ), + InProperty( + iteration::Property::kSmall, + OneByValue(that.small(), Shape::kINT64) + ), + InProperty( + iteration::Property::kByNegative, + OneByValue(that.by_negative(), Shape::kINT64) + ), + InProperty( + iteration::Property::kNegative, + OneByValue(that.negative(), Shape::kINT64) + ), + InProperty( + iteration::Property::kDeviation, + OneByValue(that.deviation(), Shape::kINT64) + ), + InProperty( + iteration::Property::kAlignment, + OneByValue(that.alignment(), Shape::kINT64) + ), + InProperty( + iteration::Property::kOptionalNumber, + that.optional_number().has_value() + ? One(&(*that.optional_number()), Shape::kINT64) + : Empty() ) ); } diff --git a/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/xmlization.cpp index f9b562353..8380edfab 100644 --- a/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/arithmetic/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1015,6 +1016,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -1747,6 +1756,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/test_jsonization_of_concrete_classes.cpp index 33e5c7d41..c2122ef08 100644 --- a/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,6 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include + +#include #include @@ -212,6 +216,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -269,14 +379,15 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .even of Something" + "Test the serialization failure on a non-finite floating-point number " + "at .ratio of Something" ) { for ( - const int64_t value + const double value : { - 9007199254740992LL, - -9007199254740992LL + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() } ) { std::shared_ptr instance( @@ -286,26 +397,72 @@ TEST_CASE( ) ); - instance->set_even(value); + instance->set_ratio(value); AssertSerializationFailsAt( *instance, - ".even" + ".ratio" ); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .offset of Something" + "Test the safe integer range at .even of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_even(value); + + AssertReportedEverywhere( + instance, + ".even", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_even(value); + + AssertNotReportedAnywhere(instance, ".even"); + } +} + +TEST_CASE( + "Test the safe integer range at .offset of Something" +) { + for ( + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -315,24 +472,45 @@ TEST_CASE( instance->set_offset(value); - AssertSerializationFailsAt( - *instance, - ".offset" + AssertReportedEverywhere( + instance, + ".offset", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_offset(value); + + AssertNotReportedAnywhere(instance, ".offset"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .small of Something" + "Test the safe integer range at .small of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -342,24 +520,45 @@ TEST_CASE( instance->set_small(value); - AssertSerializationFailsAt( - *instance, - ".small" + AssertReportedEverywhere( + instance, + ".small", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_small(value); + + AssertNotReportedAnywhere(instance, ".small"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .by_negative of Something" + "Test the safe integer range at .by_negative of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -369,24 +568,45 @@ TEST_CASE( instance->set_by_negative(value); - AssertSerializationFailsAt( - *instance, - ".by_negative" + AssertReportedEverywhere( + instance, + ".by_negative", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_by_negative(value); + + AssertNotReportedAnywhere(instance, ".by_negative"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .negative of Something" + "Test the safe integer range at .negative of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -396,24 +616,45 @@ TEST_CASE( instance->set_negative(value); - AssertSerializationFailsAt( - *instance, - ".negative" + AssertReportedEverywhere( + instance, + ".negative", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_negative(value); + + AssertNotReportedAnywhere(instance, ".negative"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .deviation of Something" + "Test the safe integer range at .deviation of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -423,25 +664,23 @@ TEST_CASE( instance->set_deviation(value); - AssertSerializationFailsAt( - *instance, - ".deviation" + AssertReportedEverywhere( + instance, + ".deviation", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } -} -TEST_CASE( - "Test the serialization failure on a non-finite floating-point number " - "at .ratio of Something" -) { for ( - const double value - : { - std::numeric_limits::infinity(), - -std::numeric_limits::infinity(), - std::numeric_limits::quiet_NaN() - } + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -449,26 +688,21 @@ TEST_CASE( ) ); - instance->set_ratio(value); + instance->set_deviation(value); - AssertSerializationFailsAt( - *instance, - ".ratio" - ); + AssertNotReportedAnywhere(instance, ".deviation"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .alignment of Something" + "Test the safe integer range at .alignment of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -478,11 +712,34 @@ TEST_CASE( instance->set_alignment(value); - AssertSerializationFailsAt( - *instance, - ".alignment" + AssertReportedEverywhere( + instance, + ".alignment", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_alignment(value); + + AssertNotReportedAnywhere(instance, ".alignment"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/arithmetic/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/include/dummy/verification.hpp index 2e9dc8072..417de10d7 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/jsonization.cpp index aa98e9886..f77411f3d 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1925,32 +1909,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/verification.cpp index 25f479269..c78b5f3bc 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/verification.cpp @@ -38,6 +38,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool AbsoluteIsSmall( @@ -141,7 +151,8 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - kSomething = 0 + kSomething = 0, + kINT64 = 1 }; // enum class Shape /** @@ -236,6 +247,8 @@ bool Something_5( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kSomething: { static const std::vector checks = { @@ -266,6 +279,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -278,19 +295,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -449,11 +527,366 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + +/** + * \brief Iterate over the values of an instance, dispatched on its runtime type. + * + * Defined below, once all the classes have been covered. + */ +std::unique_ptr DispatchOnModelType( + const types::IClass& instance, + bool recursive +); + +/** + * \brief Iterate recursively over the values of an instance referenced from + * another instance. + * + * We dispatch on the runtime type of the instance only in \ref Start so that + * we descend into the instance only once the iteration reaches it. + */ +class DispatchingIterator : public IIterator { + public: + explicit DispatchingIterator( + const types::IClass* instance + ) : + instance_(instance) { + // Intentionally empty. + } + + DispatchingIterator(const DispatchingIterator& other) : + instance_(other.instance_), + child_(other.child_ == nullptr ? nullptr : other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_ = DispatchOnModelType(*instance_, true); + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + const types::IClass* instance_; + std::unique_ptr child_; +}; // class DispatchingIterator + +std::unique_ptr Over( + const types::IClass& instance, + bool recursive +) { + if (!recursive) { + // NOTE (mristin): + // In the non-recursive mode, we verify only the instance itself, but not + // the instances that it references. + return Empty(); + } + + return common::make_unique(&instance); +} + +template +std::unique_ptr ThroughPointer( + const std::shared_ptr& instance, + bool recursive +) { + return Over(*instance, recursive); +} + +std::unique_ptr Over_ChildA( + const types::IChildA& that, + bool +) { + return Chain( + InProperty( + iteration::Property::kNumber, + OneByValue(that.number(), Shape::kINT64) + ), + InProperty( + iteration::Property::kAOnly, + OneByValue(that.a_only(), Shape::kINT64) + ) + ); +} + +std::unique_ptr Over_ChildB( + const types::IChildB& that, + bool +) { + return InProperty( + iteration::Property::kNumber, + OneByValue(that.number(), Shape::kINT64) + ); +} + std::unique_ptr Over_Something( const types::ISomething& that, - bool + bool recursive ) { - return One(&that, Shape::kSomething); + return Chain( + One(&that, Shape::kSomething), + InProperty( + iteration::Property::kNumber, + OneByValue(that.number(), Shape::kINT64) + ), + InProperty( + iteration::Property::kParent, + Over(*that.parent(), recursive) + ) + ); } /** @@ -464,6 +897,16 @@ std::unique_ptr DispatchOnModelType( bool recursive ) { switch (instance.model_type()) { + case types::ModelType::kChildA: + return Over_ChildA( + dynamic_cast(instance), + recursive + ); + case types::ModelType::kChildB: + return Over_ChildB( + dynamic_cast(instance), + recursive + ); case types::ModelType::kSomething: return Over_Something( dynamic_cast(instance), diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/xmlization.cpp index d1bbf02f9..0c41a0ca7 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1190,6 +1191,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2299,6 +2308,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp index e4cde7e04..00b3fc14d 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected ChildA") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -381,16 +489,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .number of ChildA" + "Test the safe integer range at .number of ChildA" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "ChildA", @@ -400,24 +506,45 @@ TEST_CASE( instance->set_number(value); - AssertSerializationFailsAt( - *instance, - ".number" + AssertReportedEverywhere( + instance, + ".number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "ChildA", + our::jsonization::ChildAFrom + ) ); + + instance->set_number(value); + + AssertNotReportedAnywhere(instance, ".number"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .a_only of ChildA" + "Test the safe integer range at .a_only of ChildA" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "ChildA", @@ -427,24 +554,45 @@ TEST_CASE( instance->set_a_only(value); - AssertSerializationFailsAt( - *instance, - ".a_only" + AssertReportedEverywhere( + instance, + ".a_only", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "ChildA", + our::jsonization::ChildAFrom + ) + ); + + instance->set_a_only(value); + + AssertNotReportedAnywhere(instance, ".a_only"); + } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .number of ChildB" + "Test the safe integer range at .number of ChildB" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "ChildB", @@ -454,24 +602,45 @@ TEST_CASE( instance->set_number(value); - AssertSerializationFailsAt( - *instance, - ".number" + AssertReportedEverywhere( + instance, + ".number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "ChildB", + our::jsonization::ChildBFrom + ) + ); + + instance->set_number(value); + + AssertNotReportedAnywhere(instance, ".number"); + } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .number of Something" + "Test the safe integer range at .number of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -481,10 +650,33 @@ TEST_CASE( instance->set_number(value); - AssertSerializationFailsAt( - *instance, - ".number" + AssertReportedEverywhere( + instance, + ".number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_number(value); + + AssertNotReportedAnywhere(instance, ".number"); } } diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_verification.cpp index ff3f45bc4..46ed1f8ad 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -272,5 +273,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_xmlization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_xmlization_of_concrete_classes.cpp index db419df08..9a9c1e8e8 100644 --- a/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_xmlization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/assert_statements/expected_output/test/test_xmlization_of_concrete_classes.cpp @@ -294,5 +294,106 @@ TEST_CASE("Test the de-serialization failure on a duplicate property") { REQUIRE(!deserialized.has_value()); } +/** + * \brief Patch the first recorded example of IChildA so that + * the content of the element \p xml_name is replaced by \p text. + */ +std::string PatchTheFirstExample( + const std::string& xml_name, + const std::string& text +) { + // NOTE (mristin): + // FindFilesBySuffixRecursively answers a deque, and it is already sorted, + // which is what the round-trip test above relies on as well. + const std::deque paths( + test::common::FindFilesBySuffixRecursively( + DetermineXmlDir() + / "Expected" + / "childA", + ".xml" + ) + ); + + REQUIRE(!paths.empty()); + + const std::string original(test::common::MustReadString(paths.front())); + + const std::size_t start( + original.find(our::common::Concat("<", xml_name, ">")) + + xml_name.size() + 2 + ); + const std::size_t end( + original.find(our::common::Concat("")) + ); + + return original.substr(0, start) + text + original.substr(end); +} + +/** + * \brief Read the first recorded example of IChildA with the content + * of the element \p xml_name replaced by \p text. + */ +std::shared_ptr ReadWith( + const std::string& xml_name, + const std::string& text +) { + const std::string patched(PatchTheFirstExample(xml_name, text)); + + std::istringstream iss(patched); + + our::common::expected< + std::shared_ptr, + our::xmlization::DeserializationError + > deserialized = our::xmlization::From(iss); + + INFO(our::common::Concat("De-serializing: ", patched)) + REQUIRE(deserialized.has_value()); + + std::shared_ptr casted( + std::dynamic_pointer_cast(*deserialized) + ); + REQUIRE(casted != nullptr); + + return casted; +} + +TEST_CASE("Refuse number outside the safe integer range") { + for ( + const std::string text : {"9007199254740992", "-9007199254740992"} + ) { + std::istringstream iss( + PatchTheFirstExample("number", text) + ); + + our::common::expected< + std::shared_ptr, + our::xmlization::DeserializationError + > deserialized = our::xmlization::From(iss); + + INFO(our::common::Concat("De-serializing ", text)) + REQUIRE(!deserialized.has_value()); + REQUIRE( + deserialized.error().cause + == L"Expected an integer, but got " + our::common::Utf8ToWstring(text) + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +} + +TEST_CASE("Read number at the bounds of the safe integer range") { + for ( + const int64_t value : {9007199254740991LL, -9007199254740991LL} + ) { + std::shared_ptr instance( + ReadWith( + "number", + std::to_string(value) + ) + ); + + REQUIRE(instance->number() == value); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/jsonization.cpp index 3f3e4b714..9354c0aee 100644 --- a/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1584,48 +1480,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/assignment_targets/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/verification.cpp index ef538e209..4ac175184 100644 --- a/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/verification.cpp @@ -395,8 +395,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/assignment_targets/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/xmlization.cpp index b17713e58..2099ef98e 100644 --- a/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/assignment_targets/expected_output/src/xmlization.cpp @@ -990,117 +990,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -2118,18 +2007,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/assignment_targets/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/assignment_targets/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/assignment_targets/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/assignment_targets/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/jsonization.cpp index 9f1bc24b9..95f979d36 100644 --- a/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1654,48 +1550,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/collections_in_returns/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/verification.cpp index 7ce6b05fe..a32fdfd9c 100644 --- a/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/verification.cpp @@ -412,8 +412,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/collections_in_returns/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/xmlization.cpp index abca6a6cf..d9aa550a5 100644 --- a/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/src/xmlization.cpp @@ -990,117 +990,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -2241,18 +2130,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/collections_in_returns/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/include/dummy/verification.hpp index 575886b83..2eadf8225 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/src/jsonization.cpp index 40c727419..fff9807cf 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1237,32 +1221,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/src/verification.cpp index daef0f59a..94650cd60 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool FlagsAreOrdered( @@ -108,7 +118,8 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - kSomething = 0 + kSomething = 0, + kINT64 = 1 }; // enum class Shape /** @@ -192,6 +203,8 @@ bool Something_4( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kSomething: { static const std::vector checks = { @@ -219,6 +232,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -231,19 +248,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -402,11 +480,248 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + std::unique_ptr Over_Something( const types::ISomething& that, bool ) { - return One(&that, Shape::kSomething); + return Chain( + One(&that, Shape::kSomething), + InProperty( + iteration::Property::kCount, + OneByValue(that.count(), Shape::kINT64) + ) + ); } /** diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/src/xmlization.cpp index 35b42ba0b..9cf9e42a8 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1015,6 +1016,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -1637,6 +1646,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_jsonization_of_concrete_classes.cpp index 1a8564422..4402aa885 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,6 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include + +#include #include @@ -212,6 +216,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -269,14 +379,15 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .count of Something" + "Test the serialization failure on a non-finite floating-point number " + "at .ratio of Something" ) { for ( - const int64_t value + const double value : { - 9007199254740992LL, - -9007199254740992LL + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() } ) { std::shared_ptr instance( @@ -286,27 +397,24 @@ TEST_CASE( ) ); - instance->set_count(value); + instance->set_ratio(value); AssertSerializationFailsAt( *instance, - ".count" + ".ratio" ); } } TEST_CASE( - "Test the serialization failure on a non-finite floating-point number " - "at .ratio of Something" + "Test the safe integer range at .count of Something" ) { for ( - const double value - : { - std::numeric_limits::infinity(), - -std::numeric_limits::infinity(), - std::numeric_limits::quiet_NaN() - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -314,12 +422,35 @@ TEST_CASE( ) ); - instance->set_ratio(value); + instance->set_count(value); - AssertSerializationFailsAt( - *instance, - ".ratio" + AssertReportedEverywhere( + instance, + ".count", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_count(value); + + AssertNotReportedAnywhere(instance, ".count"); } } diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_xmlization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_xmlization_of_concrete_classes.cpp index fd4a9b56b..00898ede3 100644 --- a/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_xmlization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/comparisons/expected_output/test/test_xmlization_of_concrete_classes.cpp @@ -193,10 +193,10 @@ TEST_CASE("Test the de-serialization failure on a duplicate property") { } /** - * \brief Read the first recorded example of ISomething with the content - * of the element \p xml_name replaced by \p text. + * \brief Patch the first recorded example of ISomething so that + * the content of the element \p xml_name is replaced by \p text. */ -std::shared_ptr ReadWith( +std::string PatchTheFirstExample( const std::string& xml_name, const std::string& text ) { @@ -224,9 +224,18 @@ std::shared_ptr ReadWith( original.find(our::common::Concat("")) ); - const std::string patched( - original.substr(0, start) + text + original.substr(end) - ); + return original.substr(0, start) + text + original.substr(end); +} + +/** + * \brief Read the first recorded example of ISomething with the content + * of the element \p xml_name replaced by \p text. + */ +std::shared_ptr ReadWith( + const std::string& xml_name, + const std::string& text +) { + const std::string patched(PatchTheFirstExample(xml_name, text)); std::istringstream iss(patched); @@ -356,5 +365,43 @@ TEST_CASE("Read flag from false") { REQUIRE(instance->flag() == false); } +TEST_CASE("Refuse count outside the safe integer range") { + for ( + const std::string text : {"9007199254740992", "-9007199254740992"} + ) { + std::istringstream iss( + PatchTheFirstExample("count", text) + ); + + our::common::expected< + std::shared_ptr, + our::xmlization::DeserializationError + > deserialized = our::xmlization::From(iss); + + INFO(our::common::Concat("De-serializing ", text)) + REQUIRE(!deserialized.has_value()); + REQUIRE( + deserialized.error().cause + == L"Expected an integer, but got " + our::common::Utf8ToWstring(text) + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +} + +TEST_CASE("Read count at the bounds of the safe integer range") { + for ( + const int64_t value : {9007199254740991LL, -9007199254740991LL} + ) { + std::shared_ptr instance( + ReadWith( + "count", + std::to_string(value) + ) + ); + + REQUIRE(instance->count() == value); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/include/dummy/verification.hpp index d52dd2d33..532e6791e 100644 --- a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; @@ -225,7 +237,8 @@ std::unique_ptr VerifyConstrainedbool( ); /** - * \brief Verify that the invariants hold for \p that value. + * \brief Verify that the invariants hold for \p that value, and that + * it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. * * \param that value to be verified * \return Iterable over constraint violations diff --git a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/jsonization.cpp index fd9aa0dae..63ab0870c 100644 --- a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1237,32 +1221,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/verification.cpp index 8c790af17..170fca381 100644 --- a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + namespace { /** @@ -49,7 +59,8 @@ enum class Shape : std::uint32_t { kPositiveint = 1, kPositivefloat = 2, kNonemptystring = 3, - kNonemptybytes = 4 + kNonemptybytes = 4, + kINT64 = 5 }; // enum class Shape /** @@ -110,6 +121,8 @@ bool Nonemptybytes_0( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kConstrainedbool: { static const std::vector checks = { @@ -156,6 +169,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -168,19 +185,81 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kPositiveint: + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values diff --git a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/xmlization.cpp index 7f0cc55e1..b9d8eca7a 100644 --- a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1015,6 +1016,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -1637,6 +1646,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/test_jsonization_of_concrete_classes.cpp index cae1ca597..ebc93c079 100644 --- a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,6 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include + +#include #include @@ -212,6 +216,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -269,14 +379,15 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .some_int of Something" + "Test the serialization failure on a non-finite floating-point number " + "at .some_float of Something" ) { for ( - const int64_t value + const double value : { - 9007199254740992LL, - -9007199254740992LL + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() } ) { std::shared_ptr instance( @@ -286,27 +397,24 @@ TEST_CASE( ) ); - instance->set_some_int(value); + instance->set_some_float(value); AssertSerializationFailsAt( *instance, - ".some_int" + ".some_float" ); } } TEST_CASE( - "Test the serialization failure on a non-finite floating-point number " - "at .some_float of Something" + "Test the safe integer range at .some_int of Something" ) { for ( - const double value - : { - std::numeric_limits::infinity(), - -std::numeric_limits::infinity(), - std::numeric_limits::quiet_NaN() - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -314,12 +422,35 @@ TEST_CASE( ) ); - instance->set_some_float(value); + instance->set_some_int(value); - AssertSerializationFailsAt( - *instance, - ".some_float" + AssertReportedEverywhere( + instance, + ".some_int", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_some_int(value); + + AssertNotReportedAnywhere(instance, ".some_int"); } } diff --git a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/constrained_primitives/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/jsonization.cpp index 356d00dd1..9c0f93df0 100644 --- a/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1155,48 +1051,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/custom_serialization_names/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/verification.cpp index 40ac2d582..5d2630f7e 100644 --- a/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/verification.cpp @@ -94,8 +94,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/custom_serialization_names/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/xmlization.cpp index e01c35606..4fe4964ca 100644 --- a/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/src/xmlization.cpp @@ -918,117 +918,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1550,18 +1439,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/custom_serialization_names/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/include/dummy/common.hpp index 6336e407c..fc3a01e72 100644 --- a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/include/dummy/common.hpp @@ -48,6 +48,10 @@ #pragma warning(pop) #endif +#pragma warning(push, 0) +#include +#pragma warning(pop) + namespace dummy { /** @@ -10100,6 +10104,43 @@ size_t LenTuple(const std::tuple&) { return sizeof...(T); } +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/include/dummy/verification.hpp index 0dfe6262e..5340569be 100644 --- a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/common.cpp index fbfe76815..f5900c9f3 100644 --- a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/common.cpp @@ -14060,6 +14060,26 @@ std::wstring Utf8ToWstring(const std::string& utf8_text) { return Utf8ToWstring(&(utf8_text[0]), utf8_text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/jsonization.cpp index 24fa7f2cb..57dbefa98 100644 --- a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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()) - ); + int64_t value = 0; + common::optional cause; - 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. + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -2589,32 +2573,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/verification.cpp index 40ac2d582..dd0102b6c 100644 --- a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + namespace { /** @@ -45,7 +55,7 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - + kINT64 = 0 }; // enum class Shape /** @@ -71,7 +81,13 @@ struct Check { * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -84,19 +100,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -200,15 +277,409 @@ std::unique_ptr Empty() { } /** - * Iterate over the values of the \p instance, dispatched on its runtime type. + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + +/** + * \brief Iterate over the values of an instance, dispatched on its runtime type. + * + * Defined below, once all the classes have been covered. */ std::unique_ptr DispatchOnModelType( - const types::IClass&, + const types::IClass& instance, + bool recursive +); + +/** + * \brief Iterate recursively over the values of an instance referenced from + * another instance. + * + * We dispatch on the runtime type of the instance only in \ref Start so that + * we descend into the instance only once the iteration reaches it. + */ +class DispatchingIterator : public IIterator { + public: + explicit DispatchingIterator( + const types::IClass* instance + ) : + instance_(instance) { + // Intentionally empty. + } + + DispatchingIterator(const DispatchingIterator& other) : + instance_(other.instance_), + child_(other.child_ == nullptr ? nullptr : other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_ = DispatchOnModelType(*instance_, true); + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + const types::IClass* instance_; + std::unique_ptr child_; +}; // class DispatchingIterator + +std::unique_ptr Over( + const types::IClass& instance, + bool recursive +) { + if (!recursive) { + // NOTE (mristin): + // In the non-recursive mode, we verify only the instance itself, but not + // the instances that it references. + return Empty(); + } + + return common::make_unique(&instance); +} + +template +std::unique_ptr ThroughPointer( + const std::shared_ptr& instance, + bool recursive +) { + return Over(*instance, recursive); +} + +std::unique_ptr Over_Leaf( + const types::ILeaf& that, + bool +) { + return InProperty( + iteration::Property::kValue, + OneByValue(that.value(), Shape::kINT64) + ); +} + +std::unique_ptr Over_Blossom( + const types::IBlossom& that, bool ) { - // NOTE (mristin): - // The instances of no class have anything to verify. - return Empty(); + return InProperty( + iteration::Property::kValue, + OneByValue(that.value(), Shape::kINT64) + ); +} + +std::unique_ptr Over_Something( + const types::ISomething& that, + bool recursive +) { + return Chain( + InProperty( + iteration::Property::kSomeChoice, + Over(*that.some_choice(), recursive) + ), + InProperty( + iteration::Property::kSomethingWithoutChoice, + Over(*that.something_without_choice(), recursive) + ) + ); +} + +std::unique_ptr Over_Container( + const types::IContainer& that, + bool recursive +) { + return Chain( + InProperty( + iteration::Property::kNode, + Over(*that.node(), recursive) + ), + InProperty( + iteration::Property::kSomething, + Over(*that.something(), recursive) + ) + ); +} + +/** + * Iterate over the values of the \p instance, dispatched on its runtime type. + */ +std::unique_ptr DispatchOnModelType( + const types::IClass& instance, + bool recursive +) { + switch (instance.model_type()) { + case types::ModelType::kLeaf: + return Over_Leaf( + dynamic_cast(instance), + recursive + ); + case types::ModelType::kBlossom: + return Over_Blossom( + dynamic_cast(instance), + recursive + ); + case types::ModelType::kSomething: + return Over_Something( + dynamic_cast(instance), + recursive + ); + case types::ModelType::kContainer: + return Over_Container( + dynamic_cast(instance), + recursive + ); + default: + // NOTE (mristin): + // The instances of the other classes have nothing to verify. + return Empty(); + } } // endregion Iteration over the values diff --git a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/xmlization.cpp index 9cdbd4be2..cf1d5a54f 100644 --- a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1425,6 +1426,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2704,6 +2713,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/test_jsonization_of_concrete_classes.cpp index 8a8ca6c0a..3f6a33005 100644 --- a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Branch") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -549,16 +657,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Container") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .value of Leaf" + "Test the safe integer range at .value of Leaf" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Leaf", @@ -568,24 +674,45 @@ TEST_CASE( instance->set_value(value); - AssertSerializationFailsAt( - *instance, - ".value" + AssertReportedEverywhere( + instance, + ".value", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Leaf", + our::jsonization::LeafFrom + ) + ); + + instance->set_value(value); + + AssertNotReportedAnywhere(instance, ".value"); + } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .value of Blossom" + "Test the safe integer range at .value of Blossom" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Blossom", @@ -595,11 +722,34 @@ TEST_CASE( instance->set_value(value); - AssertSerializationFailsAt( - *instance, - ".value" + AssertReportedEverywhere( + instance, + ".value", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Blossom", + our::jsonization::BlossomFrom + ) + ); + + instance->set_value(value); + + AssertNotReportedAnywhere(instance, ".value"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/test_verification.cpp index f14bfaa71..47c70a23a 100644 --- a/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/deep_class_hierarchy/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -416,5 +417,54 @@ TEST_CASE("Test verification of invalid cases for Container") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/empty_class/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/empty_class/expected_output/src/jsonization.cpp index c44ae2dc0..b7fba8d96 100644 --- a/dev/test_data/main/cpp/expected/empty_class/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/empty_class/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1098,48 +994,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/empty_class/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/empty_class/expected_output/src/verification.cpp index 40ac2d582..5d2630f7e 100644 --- a/dev/test_data/main/cpp/expected/empty_class/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/empty_class/expected_output/src/verification.cpp @@ -94,8 +94,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/empty_class/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/empty_class/expected_output/src/xmlization.cpp index d7b563e49..d70909e01 100644 --- a/dev/test_data/main/cpp/expected/empty_class/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/empty_class/expected_output/src/xmlization.cpp @@ -918,117 +918,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1489,18 +1378,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/empty_class/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/empty_class/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/empty_class/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/empty_class/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/enum/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/enum/expected_output/src/jsonization.cpp index b0a92afd7..e4920b20c 100644 --- a/dev/test_data/main/cpp/expected/enum/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/enum/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1219,48 +1115,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/enum/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/enum/expected_output/src/verification.cpp index 3b582d19c..b4565f137 100644 --- a/dev/test_data/main/cpp/expected/enum/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/enum/expected_output/src/verification.cpp @@ -129,8 +129,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/enum/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/enum/expected_output/src/xmlization.cpp index ef42fa51b..378bd4b87 100644 --- a/dev/test_data/main/cpp/expected/enum/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/enum/expected_output/src/xmlization.cpp @@ -918,117 +918,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1603,18 +1492,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/enum/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/enum/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/enum/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/enum/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/float_only/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/float_only/expected_output/src/jsonization.cpp index 7d57dcedb..5d7d4d869 100644 --- a/dev/test_data/main/cpp/expected/float_only/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/float_only/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1453,48 +1349,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/float_only/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/float_only/expected_output/src/verification.cpp index 40ac2d582..5d2630f7e 100644 --- a/dev/test_data/main/cpp/expected/float_only/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/float_only/expected_output/src/verification.cpp @@ -94,8 +94,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/float_only/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/float_only/expected_output/src/xmlization.cpp index e9c00d93a..00d0c9931 100644 --- a/dev/test_data/main/cpp/expected/float_only/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/float_only/expected_output/src/xmlization.cpp @@ -918,117 +918,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1986,18 +1875,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/float_only/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/float_only/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/float_only/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/float_only/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/for_statements/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/for_statements/expected_output/include/dummy/common.hpp index 929567512..5cf1c32bc 100644 --- a/dev/test_data/main/cpp/expected/for_statements/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/for_statements/expected_output/include/dummy/common.hpp @@ -10139,6 +10139,43 @@ size_t LenStr(const std::wstring& text); */ int64_t FloorMod(int64_t dividend, int64_t divisor); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/for_statements/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/for_statements/expected_output/include/dummy/verification.hpp index 62373e642..f12593347 100644 --- a/dev/test_data/main/cpp/expected/for_statements/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/for_statements/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/for_statements/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/for_statements/expected_output/src/common.cpp index 0960c2305..d03bb28cd 100644 --- a/dev/test_data/main/cpp/expected/for_statements/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/for_statements/expected_output/src/common.cpp @@ -14138,6 +14138,26 @@ int64_t FloorMod(int64_t dividend, int64_t divisor) { return remainder; } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/for_statements/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/for_statements/expected_output/src/jsonization.cpp index 3809d4a3c..3b24dca45 100644 --- a/dev/test_data/main/cpp/expected/for_statements/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/for_statements/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1575,32 +1559,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/for_statements/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/for_statements/expected_output/src/verification.cpp index c61034dcb..ec8569cc0 100644 --- a/dev/test_data/main/cpp/expected/for_statements/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/for_statements/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool FirstTextIsNotEmpty( @@ -246,7 +256,8 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - kSomething = 0 + kSomething = 0, + kINT64 = 1 }; // enum class Shape /** @@ -417,6 +428,8 @@ bool Something_12( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kSomething: { static const std::vector checks = { @@ -475,6 +488,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -487,19 +504,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -658,11 +736,315 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + +/** + * \brief Iterate over the values of every item of a list, one item after another. + * + * The iterator over an item is built only once the iteration reaches the item. + */ +template +class EachIterator : public IIterator { + public: + /** + * Build the iterator over the values of an item + */ + typedef std::unique_ptr (*OverItem)(const T& item, bool recursive); + + EachIterator( + const std::vector* items, + OverItem over_item, + bool recursive + ) : + items_(items), + over_item_(over_item), + recursive_(recursive), + index_(0) { + // Intentionally empty. + } + + EachIterator(const EachIterator& other) : + items_(other.items_), + over_item_(other.over_item_), + recursive_(other.recursive_), + index_(other.index_), + item_(other.item_ == nullptr ? nullptr : other.item_->Clone()) { + // Intentionally empty. + } + + void Start() override { + index_ = 0; + item_ = nullptr; + SkipDoneItems(); + } + + void Next() override { + item_->Next(); + SkipDoneItems(); + } + + bool Done() const override { + return index_ >= items_->size(); + } + + const void* Value() const override { + return item_->Value(); + } + + Shape ShapeOf() const override { + return item_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(index_) + ); + item_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + const std::vector* items_; + OverItem over_item_; + bool recursive_; + + /** + * Index of the item we currently iterate over + */ + std::size_t index_; + + /** + * Iterator over the current item, built once we reached the item + */ + std::unique_ptr item_; + + /** + * Move on to the next items, and build their iterators, until one is not done. + */ + void SkipDoneItems() { + while (index_ < items_->size()) { + if (item_ == nullptr) { + item_ = over_item_((*items_)[index_], recursive_); + item_->Start(); + } + + if (!item_->Done()) { + return; + } + + item_ = nullptr; + ++index_; + } + } +}; // class EachIterator + +template +std::unique_ptr Each( + const std::vector& items, + std::unique_ptr (*over_item)(const T& item, bool recursive), + bool recursive +) { + return common::make_unique >(&items, over_item, recursive); +} + +using listOf_int = std::vector; + +std::unique_ptr Over_int( + const int64_t& value, + bool +) { + return One(&value, Shape::kINT64); +} + +std::unique_ptr Over_listOf_int( + const listOf_int& value, + bool recursive +) { + return Each(value, &Over_int, recursive); +} + std::unique_ptr Over_Something( const types::ISomething& that, - bool + bool recursive ) { - return One(&that, Shape::kSomething); + return Chain( + One(&that, Shape::kSomething), + InProperty( + iteration::Property::kNumbers, + Over_listOf_int(that.numbers(), recursive) + ) + ); } /** diff --git a/dev/test_data/main/cpp/expected/for_statements/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/for_statements/expected_output/src/xmlization.cpp index e891d82f4..af32d36d5 100644 --- a/dev/test_data/main/cpp/expected/for_statements/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/for_statements/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1087,6 +1088,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2119,6 +2128,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/for_statements/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/for_statements/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/for_statements/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/for_statements/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/for_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/for_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp index 77f365fd6..b0f792b9d 100644 --- a/dev/test_data/main/cpp/expected/for_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/for_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Item") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -325,16 +433,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .numbers[1] of Something" + "Test the safe integer range at .numbers[1] of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -349,10 +455,38 @@ TEST_CASE( } ); - AssertSerializationFailsAt( - *instance, - ".numbers[1]" + AssertReportedEverywhere( + instance, + ".numbers[1]", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_numbers( + std::vector{ + 0LL, + value + } ); + + AssertNotReportedAnywhere(instance, ".numbers[1]"); } } diff --git a/dev/test_data/main/cpp/expected/for_statements/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/for_statements/expected_output/test/test_verification.cpp index f840a78a5..74be5fd36 100644 --- a/dev/test_data/main/cpp/expected/for_statements/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/for_statements/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -224,5 +225,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/if_statements/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/if_statements/expected_output/include/dummy/common.hpp index 929567512..5cf1c32bc 100644 --- a/dev/test_data/main/cpp/expected/if_statements/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/if_statements/expected_output/include/dummy/common.hpp @@ -10139,6 +10139,43 @@ size_t LenStr(const std::wstring& text); */ int64_t FloorMod(int64_t dividend, int64_t divisor); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/if_statements/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/if_statements/expected_output/include/dummy/verification.hpp index bedfafb04..9ab4700f0 100644 --- a/dev/test_data/main/cpp/expected/if_statements/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/if_statements/expected_output/include/dummy/verification.hpp @@ -138,6 +138,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/if_statements/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/if_statements/expected_output/src/common.cpp index 0960c2305..d03bb28cd 100644 --- a/dev/test_data/main/cpp/expected/if_statements/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/if_statements/expected_output/src/common.cpp @@ -14138,6 +14138,26 @@ int64_t FloorMod(int64_t dividend, int64_t divisor) { return remainder; } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/if_statements/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/if_statements/expected_output/src/jsonization.cpp index 0d41c427e..b630f3430 100644 --- a/dev/test_data/main/cpp/expected/if_statements/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/if_statements/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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()) - ); + int64_t value = 0; + common::optional cause; - 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. + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -2443,32 +2427,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/if_statements/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/if_statements/expected_output/src/verification.cpp index 38f3271d1..2799d5ff3 100644 --- a/dev/test_data/main/cpp/expected/if_statements/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/if_statements/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool IfWithASingleBranch( @@ -300,7 +310,8 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - kSomething = 0 + kSomething = 0, + kINT64 = 1 }; // enum class Shape /** @@ -527,6 +538,8 @@ bool Something_16( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kSomething: { static const std::vector checks = { @@ -602,6 +615,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -614,19 +631,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -785,11 +863,507 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + +/** + * \brief Iterate over the values of every item of a list, one item after another. + * + * The iterator over an item is built only once the iteration reaches the item. + */ +template +class EachIterator : public IIterator { + public: + /** + * Build the iterator over the values of an item + */ + typedef std::unique_ptr (*OverItem)(const T& item, bool recursive); + + EachIterator( + const std::vector* items, + OverItem over_item, + bool recursive + ) : + items_(items), + over_item_(over_item), + recursive_(recursive), + index_(0) { + // Intentionally empty. + } + + EachIterator(const EachIterator& other) : + items_(other.items_), + over_item_(other.over_item_), + recursive_(other.recursive_), + index_(other.index_), + item_(other.item_ == nullptr ? nullptr : other.item_->Clone()) { + // Intentionally empty. + } + + void Start() override { + index_ = 0; + item_ = nullptr; + SkipDoneItems(); + } + + void Next() override { + item_->Next(); + SkipDoneItems(); + } + + bool Done() const override { + return index_ >= items_->size(); + } + + const void* Value() const override { + return item_->Value(); + } + + Shape ShapeOf() const override { + return item_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(index_) + ); + item_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + const std::vector* items_; + OverItem over_item_; + bool recursive_; + + /** + * Index of the item we currently iterate over + */ + std::size_t index_; + + /** + * Iterator over the current item, built once we reached the item + */ + std::unique_ptr item_; + + /** + * Move on to the next items, and build their iterators, until one is not done. + */ + void SkipDoneItems() { + while (index_ < items_->size()) { + if (item_ == nullptr) { + item_ = over_item_((*items_)[index_], recursive_); + item_->Start(); + } + + if (!item_->Done()) { + return; + } + + item_ = nullptr; + ++index_; + } + } +}; // class EachIterator + +template +std::unique_ptr Each( + const std::vector& items, + std::unique_ptr (*over_item)(const T& item, bool recursive), + bool recursive +) { + return common::make_unique >(&items, over_item, recursive); +} + +/** + * \brief Iterate over the values of an instance, dispatched on its runtime type. + * + * Defined below, once all the classes have been covered. + */ +std::unique_ptr DispatchOnModelType( + const types::IClass& instance, + bool recursive +); + +/** + * \brief Iterate recursively over the values of an instance referenced from + * another instance. + * + * We dispatch on the runtime type of the instance only in \ref Start so that + * we descend into the instance only once the iteration reaches it. + */ +class DispatchingIterator : public IIterator { + public: + explicit DispatchingIterator( + const types::IClass* instance + ) : + instance_(instance) { + // Intentionally empty. + } + + DispatchingIterator(const DispatchingIterator& other) : + instance_(other.instance_), + child_(other.child_ == nullptr ? nullptr : other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_ = DispatchOnModelType(*instance_, true); + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + const types::IClass* instance_; + std::unique_ptr child_; +}; // class DispatchingIterator + +std::unique_ptr Over( + const types::IClass& instance, + bool recursive +) { + if (!recursive) { + // NOTE (mristin): + // In the non-recursive mode, we verify only the instance itself, but not + // the instances that it references. + return Empty(); + } + + return common::make_unique(&instance); +} + +template +std::unique_ptr ThroughPointer( + const std::shared_ptr& instance, + bool recursive +) { + return Over(*instance, recursive); +} + +using listOf_Parent = std::vector< + std::shared_ptr +>; + +std::unique_ptr Over_listOf_Parent( + const listOf_Parent& value, + bool recursive +) { + if (!recursive) { + return Empty(); + } + + return Each(value, &ThroughPointer, recursive); +} + +std::unique_ptr Over_ChildA( + const types::IChildA& that, + bool +) { + return InProperty( + iteration::Property::kAOnly, + OneByValue(that.a_only(), Shape::kINT64) + ); +} + +std::unique_ptr Over_ChildB( + const types::IChildB& that, + bool +) { + return InProperty( + iteration::Property::kBOnly, + OneByValue(that.b_only(), Shape::kINT64) + ); +} + +std::unique_ptr Over_Container( + const types::IContainer& that, + bool recursive +) { + return InProperty( + iteration::Property::kChildren, + that.children().has_value() + ? Over_listOf_Parent((*that.children()), recursive) + : Empty() + ); +} + std::unique_ptr Over_Something( const types::ISomething& that, - bool + bool recursive ) { - return One(&that, Shape::kSomething); + return Chain( + One(&that, Shape::kSomething), + InProperty( + iteration::Property::kNumber, + OneByValue(that.number(), Shape::kINT64) + ), + InProperty( + iteration::Property::kOptionalParent, + that.optional_parent().has_value() + ? Over(*(*that.optional_parent()), recursive) + : Empty() + ), + InProperty( + iteration::Property::kParents, + that.parents().has_value() + ? Over_listOf_Parent((*that.parents()), recursive) + : Empty() + ) + ); } /** @@ -800,6 +1374,21 @@ std::unique_ptr DispatchOnModelType( bool recursive ) { switch (instance.model_type()) { + case types::ModelType::kChildA: + return Over_ChildA( + dynamic_cast(instance), + recursive + ); + case types::ModelType::kChildB: + return Over_ChildB( + dynamic_cast(instance), + recursive + ); + case types::ModelType::kContainer: + return Over_Container( + dynamic_cast(instance), + recursive + ); case types::ModelType::kSomething: return Over_Something( dynamic_cast(instance), diff --git a/dev/test_data/main/cpp/expected/if_statements/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/if_statements/expected_output/src/xmlization.cpp index 522e25a01..8c36db842 100644 --- a/dev/test_data/main/cpp/expected/if_statements/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/if_statements/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1290,6 +1291,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2630,6 +2639,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/if_statements/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/if_statements/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/if_statements/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/if_statements/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/if_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/if_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp index 24d9c5ef4..d1cb99b6b 100644 --- a/dev/test_data/main/cpp/expected/if_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/if_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Item") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -493,16 +601,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .a_only of ChildA" + "Test the safe integer range at .a_only of ChildA" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "ChildA", @@ -512,24 +618,45 @@ TEST_CASE( instance->set_a_only(value); - AssertSerializationFailsAt( - *instance, - ".a_only" + AssertReportedEverywhere( + instance, + ".a_only", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "ChildA", + our::jsonization::ChildAFrom + ) ); + + instance->set_a_only(value); + + AssertNotReportedAnywhere(instance, ".a_only"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .b_only of ChildB" + "Test the safe integer range at .b_only of ChildB" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "ChildB", @@ -539,24 +666,45 @@ TEST_CASE( instance->set_b_only(value); - AssertSerializationFailsAt( - *instance, - ".b_only" + AssertReportedEverywhere( + instance, + ".b_only", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "ChildB", + our::jsonization::ChildBFrom + ) ); + + instance->set_b_only(value); + + AssertNotReportedAnywhere(instance, ".b_only"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .number of Something" + "Test the safe integer range at .number of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -566,10 +714,33 @@ TEST_CASE( instance->set_number(value); - AssertSerializationFailsAt( - *instance, - ".number" + AssertReportedEverywhere( + instance, + ".number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_number(value); + + AssertNotReportedAnywhere(instance, ".number"); } } diff --git a/dev/test_data/main/cpp/expected/if_statements/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/if_statements/expected_output/test/test_verification.cpp index da28fe80f..ebccc3a64 100644 --- a/dev/test_data/main/cpp/expected/if_statements/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/if_statements/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -368,5 +369,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/jsonization.cpp index 9d317cc0e..9b87ee534 100644 --- a/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1961,48 +1857,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/implementation_specific/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/verification.cpp index 9be8b909e..01136bbe1 100644 --- a/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/verification.cpp @@ -230,8 +230,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/implementation_specific/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/xmlization.cpp index 1eab58e0b..061395c53 100644 --- a/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/implementation_specific/expected_output/src/xmlization.cpp @@ -1093,117 +1093,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -2324,18 +2213,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/implementation_specific/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/implementation_specific/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/implementation_specific/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/implementation_specific/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/include/dummy/common.hpp index 231049620..d5791335f 100644 --- a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/include/dummy/common.hpp @@ -48,6 +48,10 @@ #pragma warning(pop) #endif +#pragma warning(push, 0) +#include +#pragma warning(pop) + // NOTE (mristin): // See: https://stackoverflow.com/questions/2324658/how-to-determine-the-version-of-the-c-standard-used-by-the-compiler #if ((defined(_MSVC_LANG) && _MSVC_LANG >= 201703L) || __cplusplus >= 201703L) @@ -10127,6 +10131,43 @@ size_t LenTuple(const std::tuple&) { return sizeof...(T); } +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/include/dummy/verification.hpp index 0dfe6262e..5340569be 100644 --- a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/common.cpp index fbfe76815..f5900c9f3 100644 --- a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/common.cpp @@ -14060,6 +14060,26 @@ std::wstring Utf8ToWstring(const std::string& utf8_text) { return Utf8ToWstring(&(utf8_text[0]), utf8_text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/jsonization.cpp index e01994705..768a14aa3 100644 --- a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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 - ) - ); - } + int64_t value = 0; + common::optional cause; - // 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. + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -2743,32 +2727,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/verification.cpp index 40ac2d582..3ef4e4a0d 100644 --- a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + namespace { /** @@ -45,7 +55,7 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - + kINT64 = 0 }; // enum class Shape /** @@ -71,7 +81,13 @@ struct Check { * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -84,19 +100,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -200,15 +277,277 @@ std::unique_ptr Empty() { } /** - * Iterate over the values of the \p instance, dispatched on its runtime type. + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + +/** + * \brief Iterate over the values of an instance, dispatched on its runtime type. + * + * Defined below, once all the classes have been covered. */ std::unique_ptr DispatchOnModelType( - const types::IClass&, + const types::IClass& instance, + bool recursive +); + +/** + * \brief Iterate recursively over the values of an instance referenced from + * another instance. + * + * We dispatch on the runtime type of the instance only in \ref Start so that + * we descend into the instance only once the iteration reaches it. + */ +class DispatchingIterator : public IIterator { + public: + explicit DispatchingIterator( + const types::IClass* instance + ) : + instance_(instance) { + // Intentionally empty. + } + + DispatchingIterator(const DispatchingIterator& other) : + instance_(other.instance_), + child_(other.child_ == nullptr ? nullptr : other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_ = DispatchOnModelType(*instance_, true); + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + const types::IClass* instance_; + std::unique_ptr child_; +}; // class DispatchingIterator + +std::unique_ptr Over( + const types::IClass& instance, + bool recursive +) { + if (!recursive) { + // NOTE (mristin): + // In the non-recursive mode, we verify only the instance itself, but not + // the instances that it references. + return Empty(); + } + + return common::make_unique(&instance); +} + +template +std::unique_ptr ThroughPointer( + const std::shared_ptr& instance, + bool recursive +) { + return Over(*instance, recursive); +} + +std::unique_ptr Over_UnionWithNumbers( + const types::UnionWithNumbers& value, + bool recursive +) { + switch (value.index()) { + case 0: + return Over(*common::get<0>(value), recursive); + case 1: + return Over(*common::get<1>(value), recursive); + default: + throw std::logic_error("Invalid variant index"); + } +} + +std::unique_ptr Over_WithNumber( + const types::IWithNumber& that, bool ) { - // NOTE (mristin): - // The instances of no class have anything to verify. - return Empty(); + return InProperty( + iteration::Property::kSomeInt, + OneByValue(that.some_int(), Shape::kINT64) + ); +} + +std::unique_ptr Over_Something( + const types::ISomething& that, + bool recursive +) { + return InProperty( + iteration::Property::kUnionWithNumbersProperty, + Over_UnionWithNumbers( + that.union_with_numbers_property(), + recursive + ) + ); +} + +/** + * Iterate over the values of the \p instance, dispatched on its runtime type. + */ +std::unique_ptr DispatchOnModelType( + const types::IClass& instance, + bool recursive +) { + switch (instance.model_type()) { + case types::ModelType::kWithNumber: + return Over_WithNumber( + dynamic_cast(instance), + recursive + ); + case types::ModelType::kSomething: + return Over_Something( + dynamic_cast(instance), + recursive + ); + default: + // NOTE (mristin): + // The instances of the other classes have nothing to verify. + return Empty(); + } } // endregion Iteration over the values diff --git a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/xmlization.cpp index c69eb5c93..6c52e108f 100644 --- a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1463,6 +1464,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2779,6 +2788,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/test_jsonization_of_concrete_classes.cpp index a33418660..e709e4cd3 100644 --- a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected AbstractDescendantWithoutNumbers") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -493,16 +601,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .some_int of WithNumber" + "Test the safe integer range at .some_int of WithNumber" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "WithNumber", @@ -512,11 +618,34 @@ TEST_CASE( instance->set_some_int(value); - AssertSerializationFailsAt( - *instance, - ".some_int" + AssertReportedEverywhere( + instance, + ".some_int", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "WithNumber", + our::jsonization::WithNumberFrom + ) + ); + + instance->set_some_int(value); + + AssertNotReportedAnywhere(instance, ".some_int"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/test_verification.cpp index f435f7966..7912701d2 100644 --- a/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/infallible_dispatch_among_fallible_classes/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -368,5 +369,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/include/dummy/common.hpp index e51805570..b09108de1 100644 --- a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/include/dummy/common.hpp @@ -10125,6 +10125,43 @@ size_t LenTuple(const std::tuple&) { */ int64_t FloorMod(int64_t dividend, int64_t divisor); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/include/dummy/verification.hpp index a82fb0e10..7d47dfcde 100644 --- a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; @@ -231,6 +243,11 @@ bool NoNumberIsSeven( ); /// \brief 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. bool NoSevenFrom( const std::vector& numbers, int64_t start diff --git a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/common.cpp index 822959f4d..81eb9320c 100644 --- a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/common.cpp @@ -14079,6 +14079,26 @@ int64_t FloorMod(int64_t dividend, int64_t divisor) { return remainder; } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/jsonization.cpp index 42011c4f4..bceb9b2f6 100644 --- a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1320,32 +1304,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/verification.cpp index 835127565..0951bb21e 100644 --- a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions int64_t CountTexts( @@ -80,6 +90,12 @@ bool NoSevenFrom( const std::vector& numbers, int64_t start ) { + if (start < 0) { + return false; + } + if (start >= static_cast(numbers.size())) { + return true; + } for (int64_t i = start; i < static_cast(numbers.size()); ++i) { int64_t number = numbers.at(i); if (number == 7) { @@ -139,7 +155,8 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - kSomething = 0 + kSomething = 0, + kINT64 = 1 }; // enum class Shape /** @@ -255,6 +272,8 @@ bool Something_7( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kSomething: { static const std::vector checks = { @@ -293,6 +312,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -305,19 +328,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -476,11 +560,386 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + +/** + * \brief Iterate over the values of every item of a list, one item after another. + * + * The iterator over an item is built only once the iteration reaches the item. + */ +template +class EachIterator : public IIterator { + public: + /** + * Build the iterator over the values of an item + */ + typedef std::unique_ptr (*OverItem)(const T& item, bool recursive); + + EachIterator( + const std::vector* items, + OverItem over_item, + bool recursive + ) : + items_(items), + over_item_(over_item), + recursive_(recursive), + index_(0) { + // Intentionally empty. + } + + EachIterator(const EachIterator& other) : + items_(other.items_), + over_item_(other.over_item_), + recursive_(other.recursive_), + index_(other.index_), + item_(other.item_ == nullptr ? nullptr : other.item_->Clone()) { + // Intentionally empty. + } + + void Start() override { + index_ = 0; + item_ = nullptr; + SkipDoneItems(); + } + + void Next() override { + item_->Next(); + SkipDoneItems(); + } + + bool Done() const override { + return index_ >= items_->size(); + } + + const void* Value() const override { + return item_->Value(); + } + + Shape ShapeOf() const override { + return item_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(index_) + ); + item_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + const std::vector* items_; + OverItem over_item_; + bool recursive_; + + /** + * Index of the item we currently iterate over + */ + std::size_t index_; + + /** + * Iterator over the current item, built once we reached the item + */ + std::unique_ptr item_; + + /** + * Move on to the next items, and build their iterators, until one is not done. + */ + void SkipDoneItems() { + while (index_ < items_->size()) { + if (item_ == nullptr) { + item_ = over_item_((*items_)[index_], recursive_); + item_->Start(); + } + + if (!item_->Done()) { + return; + } + + item_ = nullptr; + ++index_; + } + } +}; // class EachIterator + +template +std::unique_ptr Each( + const std::vector& items, + std::unique_ptr (*over_item)(const T& item, bool recursive), + bool recursive +) { + return common::make_unique >(&items, over_item, recursive); +} + +using listOf_int = std::vector; + +std::unique_ptr Over_int( + const int64_t& value, + bool +) { + return One(&value, Shape::kINT64); +} + +std::unique_ptr Over_listOf_int( + const listOf_int& value, + bool recursive +) { + return Each(value, &Over_int, recursive); +} + std::unique_ptr Over_Something( const types::ISomething& that, - bool + bool recursive ) { - return One(&that, Shape::kSomething); + return Chain( + One(&that, Shape::kSomething), + InProperty( + iteration::Property::kNumbers, + Over_listOf_int(that.numbers(), recursive) + ), + InProperty( + iteration::Property::kCount, + OneByValue(that.count(), Shape::kINT64) + ), + InProperty( + iteration::Property::kMaybeCount, + that.maybe_count().has_value() + ? One(&(*that.maybe_count()), Shape::kINT64) + : Empty() + ) + ); } /** diff --git a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/xmlization.cpp index 4c1051eff..120fa627e 100644 --- a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1015,6 +1016,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -1852,6 +1861,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/test_jsonization_of_concrete_classes.cpp index 7e84a6285..13035a6bf 100644 --- a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -269,16 +377,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .numbers[1] of Something" + "Test the safe integer range at .numbers[1] of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -293,24 +399,50 @@ TEST_CASE( } ); - AssertSerializationFailsAt( - *instance, - ".numbers[1]" + AssertReportedEverywhere( + instance, + ".numbers[1]", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_numbers( + std::vector{ + 0LL, + value + } + ); + + AssertNotReportedAnywhere(instance, ".numbers[1]"); + } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .count of Something" + "Test the safe integer range at .count of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -320,10 +452,33 @@ TEST_CASE( instance->set_count(value); - AssertSerializationFailsAt( - *instance, - ".count" + AssertReportedEverywhere( + instance, + ".count", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_count(value); + + AssertNotReportedAnywhere(instance, ".count"); } } diff --git a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/int_length_interplay/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/int_parsing/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/int_parsing/expected_output/include/dummy/common.hpp index ea38230b6..72a6b27df 100644 --- a/dev/test_data/main/cpp/expected/int_parsing/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/int_parsing/expected_output/include/dummy/common.hpp @@ -10169,6 +10169,43 @@ std::wstring LStrip( const std::wstring& chars ); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + /** * \brief Parse \p text as a safe integer. * @@ -10176,8 +10213,13 @@ std::wstring LStrip( * transpilation. We accept only an optional sign followed by the ASCII digits, * and only the safe integers, i.e., the integers within * -(2^53 - 1) and 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 an int64_t could hold + * larger integers. TypeScript represents the integers as 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. * * \param text to be parsed * \return parsed integer diff --git a/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/common.cpp index 2bf33fca0..c1857c347 100644 --- a/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/common.cpp @@ -14249,10 +14249,27 @@ std::wstring LStrip( return text.substr(offset); } -int64_t ParseSafeInt(const std::wstring& text) { - // NOTE: This is 2^53 - 1. - const int64_t kMaxSafeInteger = 9007199254740991LL; +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + +int64_t ParseSafeInt(const std::wstring& text) { size_t offset = 0; bool negative = false; if (!text.empty() && (text[0] == L'-' || text[0] == L'+')) { @@ -14280,7 +14297,7 @@ int64_t ParseSafeInt(const std::wstring& text) { // NOTE: The value never overflows as we check it after each digit, and // ten times the largest safe integer fits into int64_t. value = value * 10 + static_cast(character - L'0'); - if (value > kMaxSafeInteger) { + if (!IsSafeInteger(value)) { throw std::invalid_argument( "Expected a safe integer, but got a text out of its range: " + WstringToUtf8(text) diff --git a/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/jsonization.cpp index f2695409d..f210c14fb 100644 --- a/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1226,48 +1122,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/int_parsing/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/verification.cpp index ef5d337ef..e0cd83fa0 100644 --- a/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/verification.cpp @@ -355,8 +355,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/int_parsing/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/xmlization.cpp index 96bb28587..99a2535e7 100644 --- a/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/int_parsing/expected_output/src/xmlization.cpp @@ -918,117 +918,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1625,18 +1514,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/int_parsing/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/int_parsing/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/int_parsing/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/int_parsing/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/int_parsing/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/int_parsing/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/int_parsing/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/int_parsing/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/is_instance/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/is_instance/expected_output/src/jsonization.cpp index 2714ad4d4..b4554a347 100644 --- a/dev/test_data/main/cpp/expected/is_instance/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/is_instance/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -3716,48 +3612,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/is_instance/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/is_instance/expected_output/src/verification.cpp index 86bead705..861e5e31e 100644 --- a/dev/test_data/main/cpp/expected/is_instance/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/is_instance/expected_output/src/verification.cpp @@ -328,8 +328,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/is_instance/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/is_instance/expected_output/src/xmlization.cpp index 29058311b..010206703 100644 --- a/dev/test_data/main/cpp/expected/is_instance/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/is_instance/expected_output/src/xmlization.cpp @@ -1678,117 +1678,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -3572,18 +3461,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/is_instance/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/is_instance/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/is_instance/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/is_instance/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/json_types/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/include/dummy/jsonization.hpp b/dev/test_data/main/cpp/expected/json_types/expected_output/include/dummy/jsonization.hpp index 4904280c6..e1a176ae0 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/include/dummy/jsonization.hpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/include/dummy/jsonization.hpp @@ -152,6 +152,63 @@ common::expected< bool additional_properties = false ); +/** + * \brief Deserialize \p json as a JSON-able value. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able array. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able object. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +); + // endregion Deserialization // region Serialization @@ -191,6 +248,19 @@ nlohmann::json Serialize( const types::IClass& that ); +/** + * \brief Serialize the JSON-able value \p value to a JSON value. + * + * \param value JSON-able value to be serialized + * \return a copy of \p value + * \throw \ref SerializationException if \p value contains, at any depth, + * a binary or a discarded value, an infinity, a not-a-number or an integer + * outside the safe integer range [-2^53 + 1, 2^53 - 1] + */ +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +); + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/src/json_value_verification.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/src/json_value_verification.cpp index 893e09045..2ac65419a 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/src/json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/src/json_value_verification.cpp @@ -13,6 +13,80 @@ namespace dummy { namespace verification { +namespace { +/** + * \brief Describe that the \p integer of a JSON-able value lies outside + * the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeJsonAbleIntegerOutsideSafeRange( + const std::wstring& integer +) { + return common::Concat( + L"Expected a JSON-able value, but got the integer ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace + +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * \param json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +) { + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_float: { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number, so we refuse both + // instead of silently writing them out as ``null``. The caller gives us + // an ``nlohmann::json`` which may well have been constructed + // programmatically, so a non-finite number can reach us even though no + // conformant parser would ever produce one. + const double number = json.get(); + if (!std::isfinite(number)) { + return common::Concat( + L"Expected a JSON-able value, but got the number ", + std::to_wstring(number), + L", which is neither finite nor representable in JSON" + ); + } + return common::nullopt; + } + + default: + return common::nullopt; + } +} + namespace { /** @@ -34,19 +108,8 @@ common::optional CheckShallowly( ); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though ``nlohmann::json`` will happily hold - // one if it was constructed programmatically. This mirrors what the - // jsonization refuses to serialize. - if (value.is_number_float() && !std::isfinite(value.get())) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" - ) - ); + if (value.is_number()) { + return CheckJsonAbleNumber(value); } return common::nullopt; diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/src/json_value_verification.hpp b/dev/test_data/main/cpp/expected/json_types/expected_output/src/json_value_verification.hpp index 42c9230f8..f91769fe4 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/src/json_value_verification.hpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/src/json_value_verification.hpp @@ -18,6 +18,24 @@ namespace dummy { namespace verification { +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * 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 json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +); + /** * Specify the shape that the top-level JSON value itself is expected to * have, ``kAny`` if unconstrained. diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/src/jsonization.cpp index 77e398aac..410882feb 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/src/jsonization.cpp @@ -4,6 +4,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" #include "dummy/wstringification.hpp" +#include "json_value_verification.hpp" #pragma warning(push, 0) #include @@ -302,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 @@ -767,27 +664,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``DeserializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (json.is_number_float() && !std::isfinite(json.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(json.get()), - L", which is neither finite nor representable in JSON" + if (json.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(json); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -1311,6 +1200,75 @@ common::expected< ); } +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonValue(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonArray(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonObject(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + common::expected< std::shared_ptr, DeserializationError @@ -1405,48 +1363,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. * @@ -1710,7 +1626,7 @@ inline std::pair< std::pair< common::optional, common::optional -> SerializeJsonValue( +> TrySerializeJsonValue( const nlohmann::json& value ) { if (value.is_binary() || value.is_discarded()) { @@ -1734,7 +1650,7 @@ std::pair< for (const nlohmann::json& item : value) { common::optional serialized_item; common::optional error; - std::tie(serialized_item, error) = SerializeJsonValue(item); + std::tie(serialized_item, error) = TrySerializeJsonValue(item); if (error.has_value()) { error->path.segments.emplace_front( @@ -1757,7 +1673,7 @@ std::pair< for (const auto& item : value.items()) { common::optional serialized_value; common::optional error; - std::tie(serialized_value, error) = SerializeJsonValue(item.value()); + std::tie(serialized_value, error) = TrySerializeJsonValue(item.value()); if (error.has_value()) { error->path.segments.emplace_front( @@ -1775,27 +1691,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``SerializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (value.is_number_float() && !std::isfinite(value.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" + if (value.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(value); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -1807,7 +1715,7 @@ std::pair< std::pair< common::optional, common::optional -> SerializeJsonArray( +> TrySerializeJsonArray( const nlohmann::json& value ) { if (!value.is_array()) { @@ -1822,13 +1730,13 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } std::pair< common::optional, common::optional -> SerializeJsonObject( +> TrySerializeJsonObject( const nlohmann::json& value ) { if (!value.is_object()) { @@ -1843,7 +1751,7 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } /** @@ -1887,7 +1795,7 @@ std::pair< result, "value", iteration::Property::kValue, - SerializeJsonValue( + TrySerializeJsonValue( that.value() ) ); @@ -1901,7 +1809,7 @@ std::pair< result, "values", iteration::Property::kValues, - SerializeJsonArray( + TrySerializeJsonArray( that.values() ) ); @@ -1915,7 +1823,7 @@ std::pair< result, "mapping", iteration::Property::kMapping, - SerializeJsonObject( + TrySerializeJsonObject( that.mapping() ) ); @@ -1929,7 +1837,7 @@ std::pair< result, "mappingWithConstrainedKey", iteration::Property::kMappingWithConstrainedKey, - SerializeJsonObject( + TrySerializeJsonObject( that.mapping_with_constrained_key() ) ); @@ -1947,7 +1855,7 @@ std::pair< result, "optionalValue", iteration::Property::kOptionalValue, - SerializeJsonValue( + TrySerializeJsonValue( *maybe_optional_value ) ); @@ -1966,7 +1874,7 @@ std::pair< result, "optionalValues", iteration::Property::kOptionalValues, - SerializeJsonArray( + TrySerializeJsonArray( *maybe_optional_values ) ); @@ -1985,7 +1893,7 @@ std::pair< result, "optionalMapping", iteration::Property::kOptionalMapping, - SerializeJsonObject( + TrySerializeJsonObject( *maybe_optional_mapping ) ); @@ -2052,6 +1960,27 @@ nlohmann::json Serialize( return std::move(*result); } +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +) { + common::optional result; + common::optional error; + + std::tie( + result, + error + ) = TrySerializeJsonValue(value); + + if (error.has_value()) { + throw SerializationException( + std::move(error->cause), + std::move(error->path) + ); + } + + return std::move(*result); +} + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/src/verification.cpp index cf766070b..c8100a688 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/src/verification.cpp @@ -99,7 +99,7 @@ const std::vector& ChecksOf(Shape shape) { case Shape::kJSONArray: case Shape::kJSONObject: // NOTE (mristin): - // The JSON-able values are verified by the nested verification. + // These values are verified by the nested verification. return kNoChecks; default: throw std::logic_error( diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/src/xml_rpc.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/src/xml_rpc.cpp index abf1d37d3..5d8c4b8a6 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/src/xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/src/xml_rpc.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "xml_rpc.hpp" +#include "json_value_verification.hpp" #include "xml_common.hpp" #pragma warning(push, 0) @@ -902,23 +903,21 @@ common::optional SerializeValueBodyTo( writer.SerializeString(value.get() ? "1" : "0"); writer.StopElement("boolean"); } else if (value.is_number()) { - const double number = value.get(); - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value. ``SelfClosingWriter::SerializeDouble`` would - // happily write "INF" or "NaN" -- the xs:double spellings -- which + // We check the number exactly as the verification and the JSON + // de/serialization do. In particular, JSON knows neither an infinity nor + // a not-a-number, and ``SelfClosingWriter::SerializeDouble`` would happily + // write "INF" or "NaN" -- the xs:double spellings -- which // ``DeserializeDoubleBodyFrom`` deliberately refuses to read back. - if (!std::isfinite(number)) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(number), - L", which is neither finite nor representable in JSON" - ) - ); + common::optional cause( + verification::CheckJsonAbleNumber(value) + ); + if (cause.has_value()) { + return common::make_optional(std::move(*cause)); } + const double number = value.get(); + writer.StartElementInNoNamespace( "double", undeclare_namespace ); diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/src/xmlization.cpp index e67bfabdb..3cee01c98 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/src/xmlization.cpp @@ -919,117 +919,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1743,18 +1632,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/json_types/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_json_value_verification.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_json_value_verification.cpp index 64626bf8a..7b55f07c3 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_json_value_verification.cpp @@ -7,6 +7,7 @@ * of any particular meta-model. */ +#include "dummy/jsonization.hpp" #include "dummy/verification.hpp" #include "json_value_verification.hpp" @@ -15,6 +16,7 @@ #include #include +#include #include #pragma warning(pop) @@ -248,5 +250,57 @@ TEST_CASE("Reject a value whose top-level shape does not match kObject") { REQUIRE(errors.size() == 1); } +TEST_CASE("Reject an integer just outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + const nlohmann::json& value = a_case.first; + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + const std::vector errors(CollectErrors(verificator)); + REQUIRE(errors.size() == 1); + REQUIRE( + errors[0].cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + + REQUIRE(!our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE_THROWS_AS( + our::jsonization::SerializeJsonValue(value), + our::jsonization::SerializationException + ); + } +} + +TEST_CASE("Accept the integers at the bounds of the safe integer range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + const nlohmann::json value(integer); + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + REQUIRE(CollectErrors(verificator).empty()); + + REQUIRE(our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE(our::jsonization::SerializeJsonValue(value) == value); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_jsonization_of_concrete_classes.cpp index f87da90bb..10003c0d3 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,6 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include + +#include #include @@ -128,6 +132,194 @@ const std::filesystem::path& DetermineErrorDir() { return *result; } +template +std::shared_ptr MustDeserializeTheFirstExpected( + const std::string& model_type, + std::function< + our::common::expected< + std::shared_ptr, + our::jsonization::DeserializationError + >(const nlohmann::json&, bool) + > deserialization_function +) { + const std::deque paths( + test::common::FindFilesBySuffixRecursively( + DetermineJsonDir() / "Expected" / model_type, + ".json" + ) + ); + + INFO("We expect at least one recorded example of " + model_type) + REQUIRE(!paths.empty()); + + const nlohmann::json json = test::common::jsonization::MustReadJson( + paths.front() + ); + + our::common::expected< + std::shared_ptr, + our::jsonization::DeserializationError + > deserialized = deserialization_function(json, false); + + INFO( + our::common::Concat( + "Failed to de-serialize from ", + paths.front().string() + ) + ) + REQUIRE(deserialized.has_value()); + + return deserialized.value(); +} + +/** + * Assert that \p that instance can not be serialized to JSON, and that + * the failure is reported at \p expected_path. + */ +void AssertSerializationFailsAt( + const our::types::IClass& that, + const std::string& expected_path +) { + try { + our::jsonization::Serialize(that); + } catch (const our::jsonization::SerializationException& exception) { + const std::string observed_path( + our::common::WstringToUtf8( + exception.path().ToWstring() + ) + ); + + INFO( + our::common::Concat( + "Expected the serialization to fail at ", + expected_path, + ", but it failed at ", + observed_path, + ": ", + our::common::WstringToUtf8(exception.cause()) + ) + ) + REQUIRE(observed_path == expected_path); + + return; + } + + INFO( + our::common::Concat( + "Expected the serialization to fail at ", + expected_path, + ", but it succeeded" + ) + ) + REQUIRE(false); +} + +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -184,5 +376,105 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } } +TEST_CASE( + "Test the safe integer range at .value of Something" +) { + for ( + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} + ) { + const nlohmann::json value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_value(value); + + AssertReportedEverywhere( + instance, + ".value", + our::common::Concat( + L"Expected a JSON-able value, but got the integer ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const nlohmann::json value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_value(value); + + AssertNotReportedAnywhere(instance, ".value"); + } +} + +TEST_CASE( + "Test the safe integer range at .values[1] of Something" +) { + for ( + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} + ) { + const nlohmann::json value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_values( + nlohmann::json::array({nlohmann::json(0), value}) + ); + + AssertReportedEverywhere( + instance, + ".values[1]", + our::common::Concat( + L"Expected a JSON-able value, but got the integer ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const nlohmann::json value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_values( + nlohmann::json::array({nlohmann::json(0), value}) + ); + + AssertNotReportedAnywhere(instance, ".values[1]"); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_xml_rpc.cpp b/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_xml_rpc.cpp index 89bda2904..82937fdcd 100644 --- a/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/json_types/expected_output/test/test_xml_rpc.cpp @@ -16,7 +16,10 @@ #include #include #include +#include #include +#include +#include #pragma warning(pop) #include @@ -93,6 +96,42 @@ TEST_CASE("Serialize rejects a non-finite number") { } } +TEST_CASE("Serialize rejects an integer outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, a_case.first) + ); + REQUIRE(error.has_value()); + REQUIRE( + error->cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +} + +TEST_CASE("Serialize accepts the integers at the bounds of the safe range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, nlohmann::json(integer)) + ); + REQUIRE(!error.has_value()); + } +} + TEST_CASE("Deserialize rejects a non-finite number") { // NOTE (mristin): // The xs:double spellings of the three special values are deliberately diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/include/dummy/common.hpp index 6336e407c..fc3a01e72 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/include/dummy/common.hpp @@ -48,6 +48,10 @@ #pragma warning(pop) #endif +#pragma warning(push, 0) +#include +#pragma warning(pop) + namespace dummy { /** @@ -10100,6 +10104,43 @@ size_t LenTuple(const std::tuple&) { return sizeof...(T); } +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/include/dummy/jsonization.hpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/include/dummy/jsonization.hpp index 4904280c6..e1a176ae0 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/include/dummy/jsonization.hpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/include/dummy/jsonization.hpp @@ -152,6 +152,63 @@ common::expected< bool additional_properties = false ); +/** + * \brief Deserialize \p json as a JSON-able value. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able array. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able object. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +); + // endregion Deserialization // region Serialization @@ -191,6 +248,19 @@ nlohmann::json Serialize( const types::IClass& that ); +/** + * \brief Serialize the JSON-able value \p value to a JSON value. + * + * \param value JSON-able value to be serialized + * \return a copy of \p value + * \throw \ref SerializationException if \p value contains, at any depth, + * a binary or a discarded value, an infinity, a not-a-number or an integer + * outside the safe integer range [-2^53 + 1, 2^53 - 1] + */ +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +); + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/common.cpp index fbfe76815..f5900c9f3 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/common.cpp @@ -14060,6 +14060,26 @@ std::wstring Utf8ToWstring(const std::string& utf8_text) { return Utf8ToWstring(&(utf8_text[0]), utf8_text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/json_value_verification.cpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/json_value_verification.cpp index 893e09045..2ac65419a 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/json_value_verification.cpp @@ -13,6 +13,80 @@ namespace dummy { namespace verification { +namespace { +/** + * \brief Describe that the \p integer of a JSON-able value lies outside + * the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeJsonAbleIntegerOutsideSafeRange( + const std::wstring& integer +) { + return common::Concat( + L"Expected a JSON-able value, but got the integer ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace + +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * \param json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +) { + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_float: { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number, so we refuse both + // instead of silently writing them out as ``null``. The caller gives us + // an ``nlohmann::json`` which may well have been constructed + // programmatically, so a non-finite number can reach us even though no + // conformant parser would ever produce one. + const double number = json.get(); + if (!std::isfinite(number)) { + return common::Concat( + L"Expected a JSON-able value, but got the number ", + std::to_wstring(number), + L", which is neither finite nor representable in JSON" + ); + } + return common::nullopt; + } + + default: + return common::nullopt; + } +} + namespace { /** @@ -34,19 +108,8 @@ common::optional CheckShallowly( ); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though ``nlohmann::json`` will happily hold - // one if it was constructed programmatically. This mirrors what the - // jsonization refuses to serialize. - if (value.is_number_float() && !std::isfinite(value.get())) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" - ) - ); + if (value.is_number()) { + return CheckJsonAbleNumber(value); } return common::nullopt; diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/json_value_verification.hpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/json_value_verification.hpp index 42c9230f8..f91769fe4 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/json_value_verification.hpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/json_value_verification.hpp @@ -18,6 +18,24 @@ namespace dummy { namespace verification { +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * 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 json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +); + /** * Specify the shape that the top-level JSON value itself is expected to * have, ``kAny`` if unconstrained. diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/jsonization.cpp index 8cccedbc2..36e0f81d8 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/jsonization.cpp @@ -4,6 +4,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" #include "dummy/wstringification.hpp" +#include "json_value_verification.hpp" #pragma warning(push, 0) #include @@ -302,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 @@ -767,27 +664,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``DeserializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (json.is_number_float() && !std::isfinite(json.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(json.get()), - L", which is neither finite nor representable in JSON" + if (json.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(json); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -1588,6 +1477,75 @@ common::expected< ); } +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonValue(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonArray(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonObject(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + common::expected< std::shared_ptr, DeserializationError @@ -1682,48 +1640,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. * @@ -2010,7 +1926,7 @@ const T& Deref(const T& value) { std::pair< common::optional, common::optional -> SerializeJsonValue( +> TrySerializeJsonValue( const nlohmann::json& value ) { if (value.is_binary() || value.is_discarded()) { @@ -2034,7 +1950,7 @@ std::pair< for (const nlohmann::json& item : value) { common::optional serialized_item; common::optional error; - std::tie(serialized_item, error) = SerializeJsonValue(item); + std::tie(serialized_item, error) = TrySerializeJsonValue(item); if (error.has_value()) { error->path.segments.emplace_front( @@ -2057,7 +1973,7 @@ std::pair< for (const auto& item : value.items()) { common::optional serialized_value; common::optional error; - std::tie(serialized_value, error) = SerializeJsonValue(item.value()); + std::tie(serialized_value, error) = TrySerializeJsonValue(item.value()); if (error.has_value()) { error->path.segments.emplace_front( @@ -2075,27 +1991,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``SerializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (value.is_number_float() && !std::isfinite(value.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" + if (value.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(value); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -2107,7 +2015,7 @@ std::pair< std::pair< common::optional, common::optional -> SerializeJsonArray( +> TrySerializeJsonArray( const nlohmann::json& value ) { if (!value.is_array()) { @@ -2122,13 +2030,13 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } std::pair< common::optional, common::optional -> SerializeJsonObject( +> TrySerializeJsonObject( const nlohmann::json& value ) { if (!value.is_object()) { @@ -2143,7 +2051,7 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } /** @@ -2342,7 +2250,7 @@ std::pair< iteration::Property::kValues, SerializeListWithFallible( that.values(), - SerializeJsonValue + TrySerializeJsonValue ) ); if (error.has_value()) { @@ -2358,9 +2266,9 @@ std::pair< SerializeTuple4( that.tuple_with_json(), SerializeWstring, - SerializeJsonValue, - SerializeJsonArray, - SerializeJsonObject + TrySerializeJsonValue, + TrySerializeJsonArray, + TrySerializeJsonObject ) ); if (error.has_value()) { @@ -2381,7 +2289,7 @@ std::pair< iteration::Property::kOptionalObjects, SerializeListWithFallible( *maybe_optional_objects, - SerializeJsonObject + TrySerializeJsonObject ) ); if (error.has_value()) { @@ -2399,7 +2307,7 @@ std::pair< result, "optionalObject", iteration::Property::kOptionalObject, - SerializeJsonObject( + TrySerializeJsonObject( *maybe_optional_object ) ); @@ -2466,6 +2374,27 @@ nlohmann::json Serialize( return std::move(*result); } +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +) { + common::optional result; + common::optional error; + + std::tie( + result, + error + ) = TrySerializeJsonValue(value); + + if (error.has_value()) { + throw SerializationException( + std::move(error->cause), + std::move(error->path) + ); + } + + return std::move(*result); +} + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/verification.cpp index f60000f30..8d40ad4e5 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/verification.cpp @@ -82,7 +82,7 @@ const std::vector& ChecksOf(Shape shape) { case Shape::kJSONArray: case Shape::kJSONObject: // NOTE (mristin): - // The JSON-able values are verified by the nested verification. + // These values are verified by the nested verification. return kNoChecks; default: throw std::logic_error( diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/xml_rpc.cpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/xml_rpc.cpp index abf1d37d3..5d8c4b8a6 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/xml_rpc.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "xml_rpc.hpp" +#include "json_value_verification.hpp" #include "xml_common.hpp" #pragma warning(push, 0) @@ -902,23 +903,21 @@ common::optional SerializeValueBodyTo( writer.SerializeString(value.get() ? "1" : "0"); writer.StopElement("boolean"); } else if (value.is_number()) { - const double number = value.get(); - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value. ``SelfClosingWriter::SerializeDouble`` would - // happily write "INF" or "NaN" -- the xs:double spellings -- which + // We check the number exactly as the verification and the JSON + // de/serialization do. In particular, JSON knows neither an infinity nor + // a not-a-number, and ``SelfClosingWriter::SerializeDouble`` would happily + // write "INF" or "NaN" -- the xs:double spellings -- which // ``DeserializeDoubleBodyFrom`` deliberately refuses to read back. - if (!std::isfinite(number)) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(number), - L", which is neither finite nor representable in JSON" - ) - ); + common::optional cause( + verification::CheckJsonAbleNumber(value) + ); + if (cause.has_value()) { + return common::make_optional(std::move(*cause)); } + const double number = value.get(); + writer.StartElementInNoNamespace( "double", undeclare_namespace ); diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/xmlization.cpp index fb00c08c0..c1f0b491d 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/src/xmlization.cpp @@ -919,117 +919,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -2206,18 +2095,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_json_value_verification.cpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_json_value_verification.cpp index 64626bf8a..7b55f07c3 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_json_value_verification.cpp @@ -7,6 +7,7 @@ * of any particular meta-model. */ +#include "dummy/jsonization.hpp" #include "dummy/verification.hpp" #include "json_value_verification.hpp" @@ -15,6 +16,7 @@ #include #include +#include #include #pragma warning(pop) @@ -248,5 +250,57 @@ TEST_CASE("Reject a value whose top-level shape does not match kObject") { REQUIRE(errors.size() == 1); } +TEST_CASE("Reject an integer just outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + const nlohmann::json& value = a_case.first; + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + const std::vector errors(CollectErrors(verificator)); + REQUIRE(errors.size() == 1); + REQUIRE( + errors[0].cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + + REQUIRE(!our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE_THROWS_AS( + our::jsonization::SerializeJsonValue(value), + our::jsonization::SerializationException + ); + } +} + +TEST_CASE("Accept the integers at the bounds of the safe integer range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + const nlohmann::json value(integer); + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + REQUIRE(CollectErrors(verificator).empty()); + + REQUIRE(our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE(our::jsonization::SerializeJsonValue(value) == value); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_xml_rpc.cpp b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_xml_rpc.cpp index 89bda2904..82937fdcd 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_collections/expected_output/test/test_xml_rpc.cpp @@ -16,7 +16,10 @@ #include #include #include +#include #include +#include +#include #pragma warning(pop) #include @@ -93,6 +96,42 @@ TEST_CASE("Serialize rejects a non-finite number") { } } +TEST_CASE("Serialize rejects an integer outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, a_case.first) + ); + REQUIRE(error.has_value()); + REQUIRE( + error->cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +} + +TEST_CASE("Serialize accepts the integers at the bounds of the safe range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, nlohmann::json(integer)) + ); + REQUIRE(!error.has_value()); + } +} + TEST_CASE("Deserialize rejects a non-finite number") { // NOTE (mristin): // The xs:double spellings of the three special values are deliberately diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/include/dummy/common.hpp index 6336e407c..fc3a01e72 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/include/dummy/common.hpp @@ -48,6 +48,10 @@ #pragma warning(pop) #endif +#pragma warning(push, 0) +#include +#pragma warning(pop) + namespace dummy { /** @@ -10100,6 +10104,43 @@ size_t LenTuple(const std::tuple&) { return sizeof...(T); } +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/include/dummy/jsonization.hpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/include/dummy/jsonization.hpp index 4904280c6..e1a176ae0 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/include/dummy/jsonization.hpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/include/dummy/jsonization.hpp @@ -152,6 +152,63 @@ common::expected< bool additional_properties = false ); +/** + * \brief Deserialize \p json as a JSON-able value. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able array. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able object. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +); + // endregion Deserialization // region Serialization @@ -191,6 +248,19 @@ nlohmann::json Serialize( const types::IClass& that ); +/** + * \brief Serialize the JSON-able value \p value to a JSON value. + * + * \param value JSON-able value to be serialized + * \return a copy of \p value + * \throw \ref SerializationException if \p value contains, at any depth, + * a binary or a discarded value, an infinity, a not-a-number or an integer + * outside the safe integer range [-2^53 + 1, 2^53 - 1] + */ +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +); + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/common.cpp index fbfe76815..f5900c9f3 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/common.cpp @@ -14060,6 +14060,26 @@ std::wstring Utf8ToWstring(const std::string& utf8_text) { return Utf8ToWstring(&(utf8_text[0]), utf8_text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/json_value_verification.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/json_value_verification.cpp index 893e09045..2ac65419a 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/json_value_verification.cpp @@ -13,6 +13,80 @@ namespace dummy { namespace verification { +namespace { +/** + * \brief Describe that the \p integer of a JSON-able value lies outside + * the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeJsonAbleIntegerOutsideSafeRange( + const std::wstring& integer +) { + return common::Concat( + L"Expected a JSON-able value, but got the integer ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace + +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * \param json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +) { + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_float: { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number, so we refuse both + // instead of silently writing them out as ``null``. The caller gives us + // an ``nlohmann::json`` which may well have been constructed + // programmatically, so a non-finite number can reach us even though no + // conformant parser would ever produce one. + const double number = json.get(); + if (!std::isfinite(number)) { + return common::Concat( + L"Expected a JSON-able value, but got the number ", + std::to_wstring(number), + L", which is neither finite nor representable in JSON" + ); + } + return common::nullopt; + } + + default: + return common::nullopt; + } +} + namespace { /** @@ -34,19 +108,8 @@ common::optional CheckShallowly( ); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though ``nlohmann::json`` will happily hold - // one if it was constructed programmatically. This mirrors what the - // jsonization refuses to serialize. - if (value.is_number_float() && !std::isfinite(value.get())) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" - ) - ); + if (value.is_number()) { + return CheckJsonAbleNumber(value); } return common::nullopt; diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/json_value_verification.hpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/json_value_verification.hpp index 42c9230f8..f91769fe4 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/json_value_verification.hpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/json_value_verification.hpp @@ -18,6 +18,24 @@ namespace dummy { namespace verification { +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * 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 json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +); + /** * Specify the shape that the top-level JSON value itself is expected to * have, ``kAny`` if unconstrained. diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/jsonization.cpp index e5c8d4f76..4d9ea6a94 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/jsonization.cpp @@ -4,6 +4,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" #include "dummy/wstringification.hpp" +#include "json_value_verification.hpp" #pragma warning(push, 0) #include @@ -302,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 @@ -767,27 +664,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``DeserializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (json.is_number_float() && !std::isfinite(json.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(json.get()), - L", which is neither finite nor representable in JSON" + if (json.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(json); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -1256,6 +1145,75 @@ common::expected< ); } +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonValue(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonArray(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonObject(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + common::expected< std::shared_ptr, DeserializationError @@ -1350,48 +1308,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. * @@ -1655,7 +1571,7 @@ inline std::pair< std::pair< common::optional, common::optional -> SerializeJsonValue( +> TrySerializeJsonValue( const nlohmann::json& value ) { if (value.is_binary() || value.is_discarded()) { @@ -1679,7 +1595,7 @@ std::pair< for (const nlohmann::json& item : value) { common::optional serialized_item; common::optional error; - std::tie(serialized_item, error) = SerializeJsonValue(item); + std::tie(serialized_item, error) = TrySerializeJsonValue(item); if (error.has_value()) { error->path.segments.emplace_front( @@ -1702,7 +1618,7 @@ std::pair< for (const auto& item : value.items()) { common::optional serialized_value; common::optional error; - std::tie(serialized_value, error) = SerializeJsonValue(item.value()); + std::tie(serialized_value, error) = TrySerializeJsonValue(item.value()); if (error.has_value()) { error->path.segments.emplace_front( @@ -1720,27 +1636,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``SerializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (value.is_number_float() && !std::isfinite(value.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" + if (value.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(value); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -1752,7 +1660,7 @@ std::pair< std::pair< common::optional, common::optional -> SerializeJsonArray( +> TrySerializeJsonArray( const nlohmann::json& value ) { if (!value.is_array()) { @@ -1767,13 +1675,13 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } std::pair< common::optional, common::optional -> SerializeJsonObject( +> TrySerializeJsonObject( const nlohmann::json& value ) { if (!value.is_object()) { @@ -1788,7 +1696,7 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } /** @@ -1832,7 +1740,7 @@ std::pair< result, "mapping", iteration::Property::kMapping, - SerializeJsonObject( + TrySerializeJsonObject( that.mapping() ) ); @@ -1846,7 +1754,7 @@ std::pair< result, "values", iteration::Property::kValues, - SerializeJsonArray( + TrySerializeJsonArray( that.values() ) ); @@ -1864,7 +1772,7 @@ std::pair< result, "optionalMapping", iteration::Property::kOptionalMapping, - SerializeJsonObject( + TrySerializeJsonObject( *maybe_optional_mapping ) ); @@ -1883,7 +1791,7 @@ std::pair< result, "optionalValue", iteration::Property::kOptionalValue, - SerializeJsonValue( + TrySerializeJsonValue( *maybe_optional_value ) ); @@ -1950,6 +1858,27 @@ nlohmann::json Serialize( return std::move(*result); } +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +) { + common::optional result; + common::optional error; + + std::tie( + result, + error + ) = TrySerializeJsonValue(value); + + if (error.has_value()) { + throw SerializationException( + std::move(error->cause), + std::move(error->path) + ); + } + + return std::move(*result); +} + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/verification.cpp index 4e676c021..832e19c50 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/verification.cpp @@ -298,7 +298,7 @@ const std::vector& ChecksOf(Shape shape) { case Shape::kJSONArray: case Shape::kJSONObject: // NOTE (mristin): - // The JSON-able values are verified by the nested verification. + // These values are verified by the nested verification. return kNoChecks; default: throw std::logic_error( diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/xml_rpc.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/xml_rpc.cpp index abf1d37d3..5d8c4b8a6 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/xml_rpc.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "xml_rpc.hpp" +#include "json_value_verification.hpp" #include "xml_common.hpp" #pragma warning(push, 0) @@ -902,23 +903,21 @@ common::optional SerializeValueBodyTo( writer.SerializeString(value.get() ? "1" : "0"); writer.StopElement("boolean"); } else if (value.is_number()) { - const double number = value.get(); - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value. ``SelfClosingWriter::SerializeDouble`` would - // happily write "INF" or "NaN" -- the xs:double spellings -- which + // We check the number exactly as the verification and the JSON + // de/serialization do. In particular, JSON knows neither an infinity nor + // a not-a-number, and ``SelfClosingWriter::SerializeDouble`` would happily + // write "INF" or "NaN" -- the xs:double spellings -- which // ``DeserializeDoubleBodyFrom`` deliberately refuses to read back. - if (!std::isfinite(number)) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(number), - L", which is neither finite nor representable in JSON" - ) - ); + common::optional cause( + verification::CheckJsonAbleNumber(value) + ); + if (cause.has_value()) { + return common::make_optional(std::move(*cause)); } + const double number = value.get(); + writer.StartElementInNoNamespace( "double", undeclare_namespace ); diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/xmlization.cpp index bc98a34c0..44ab087d2 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/src/xmlization.cpp @@ -919,117 +919,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1688,18 +1577,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_json_value_verification.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_json_value_verification.cpp index 64626bf8a..7b55f07c3 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_json_value_verification.cpp @@ -7,6 +7,7 @@ * of any particular meta-model. */ +#include "dummy/jsonization.hpp" #include "dummy/verification.hpp" #include "json_value_verification.hpp" @@ -15,6 +16,7 @@ #include #include +#include #include #pragma warning(pop) @@ -248,5 +250,57 @@ TEST_CASE("Reject a value whose top-level shape does not match kObject") { REQUIRE(errors.size() == 1); } +TEST_CASE("Reject an integer just outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + const nlohmann::json& value = a_case.first; + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + const std::vector errors(CollectErrors(verificator)); + REQUIRE(errors.size() == 1); + REQUIRE( + errors[0].cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + + REQUIRE(!our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE_THROWS_AS( + our::jsonization::SerializeJsonValue(value), + our::jsonization::SerializationException + ); + } +} + +TEST_CASE("Accept the integers at the bounds of the safe integer range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + const nlohmann::json value(integer); + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + REQUIRE(CollectErrors(verificator).empty()); + + REQUIRE(our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE(our::jsonization::SerializeJsonValue(value) == value); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_jsonization_of_concrete_classes.cpp index f87da90bb..4ca8ccd7f 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,6 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include + +#include #include @@ -128,6 +132,194 @@ const std::filesystem::path& DetermineErrorDir() { return *result; } +template +std::shared_ptr MustDeserializeTheFirstExpected( + const std::string& model_type, + std::function< + our::common::expected< + std::shared_ptr, + our::jsonization::DeserializationError + >(const nlohmann::json&, bool) + > deserialization_function +) { + const std::deque paths( + test::common::FindFilesBySuffixRecursively( + DetermineJsonDir() / "Expected" / model_type, + ".json" + ) + ); + + INFO("We expect at least one recorded example of " + model_type) + REQUIRE(!paths.empty()); + + const nlohmann::json json = test::common::jsonization::MustReadJson( + paths.front() + ); + + our::common::expected< + std::shared_ptr, + our::jsonization::DeserializationError + > deserialized = deserialization_function(json, false); + + INFO( + our::common::Concat( + "Failed to de-serialize from ", + paths.front().string() + ) + ) + REQUIRE(deserialized.has_value()); + + return deserialized.value(); +} + +/** + * Assert that \p that instance can not be serialized to JSON, and that + * the failure is reported at \p expected_path. + */ +void AssertSerializationFailsAt( + const our::types::IClass& that, + const std::string& expected_path +) { + try { + our::jsonization::Serialize(that); + } catch (const our::jsonization::SerializationException& exception) { + const std::string observed_path( + our::common::WstringToUtf8( + exception.path().ToWstring() + ) + ); + + INFO( + our::common::Concat( + "Expected the serialization to fail at ", + expected_path, + ", but it failed at ", + observed_path, + ": ", + our::common::WstringToUtf8(exception.cause()) + ) + ) + REQUIRE(observed_path == expected_path); + + return; + } + + INFO( + our::common::Concat( + "Expected the serialization to fail at ", + expected_path, + ", but it succeeded" + ) + ) + REQUIRE(false); +} + +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -184,5 +376,57 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } } +TEST_CASE( + "Test the safe integer range at .values[1] of Something" +) { + for ( + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} + ) { + const nlohmann::json value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_values( + nlohmann::json::array({nlohmann::json(0), value}) + ); + + AssertReportedEverywhere( + instance, + ".values[1]", + our::common::Concat( + L"Expected a JSON-able value, but got the integer ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const nlohmann::json value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_values( + nlohmann::json::array({nlohmann::json(0), value}) + ); + + AssertNotReportedAnywhere(instance, ".values[1]"); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_xml_rpc.cpp b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_xml_rpc.cpp index 89bda2904..82937fdcd 100644 --- a/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/json_types_in_invariants/expected_output/test/test_xml_rpc.cpp @@ -16,7 +16,10 @@ #include #include #include +#include #include +#include +#include #pragma warning(pop) #include @@ -93,6 +96,42 @@ TEST_CASE("Serialize rejects a non-finite number") { } } +TEST_CASE("Serialize rejects an integer outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, a_case.first) + ); + REQUIRE(error.has_value()); + REQUIRE( + error->cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +} + +TEST_CASE("Serialize accepts the integers at the bounds of the safe range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, nlohmann::json(integer)) + ); + REQUIRE(!error.has_value()); + } +} + TEST_CASE("Deserialize rejects a non-finite number") { // NOTE (mristin): // The xs:double spellings of the three special values are deliberately diff --git a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/include/dummy/verification.hpp index 3152a059b..ba5e8c398 100644 --- a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/include/dummy/verification.hpp @@ -138,6 +138,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/jsonization.cpp index 6288ee477..de70b6ac6 100644 --- a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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()) - ); + int64_t value = 0; + common::optional cause; - 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. + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -2052,32 +2036,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/verification.cpp index ac426bb53..99520fea6 100644 --- a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions std::vector NamesOf( @@ -110,7 +120,8 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - kSomething = 0 + kSomething = 0, + kINT64 = 1 }; // enum class Shape /** @@ -204,6 +215,8 @@ bool Something_4( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kSomething: { static const std::vector checks = { @@ -232,6 +245,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -244,19 +261,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -415,11 +493,477 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + +/** + * \brief Iterate over the values of every item of a list, one item after another. + * + * The iterator over an item is built only once the iteration reaches the item. + */ +template +class EachIterator : public IIterator { + public: + /** + * Build the iterator over the values of an item + */ + typedef std::unique_ptr (*OverItem)(const T& item, bool recursive); + + EachIterator( + const std::vector* items, + OverItem over_item, + bool recursive + ) : + items_(items), + over_item_(over_item), + recursive_(recursive), + index_(0) { + // Intentionally empty. + } + + EachIterator(const EachIterator& other) : + items_(other.items_), + over_item_(other.over_item_), + recursive_(other.recursive_), + index_(other.index_), + item_(other.item_ == nullptr ? nullptr : other.item_->Clone()) { + // Intentionally empty. + } + + void Start() override { + index_ = 0; + item_ = nullptr; + SkipDoneItems(); + } + + void Next() override { + item_->Next(); + SkipDoneItems(); + } + + bool Done() const override { + return index_ >= items_->size(); + } + + const void* Value() const override { + return item_->Value(); + } + + Shape ShapeOf() const override { + return item_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(index_) + ); + item_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + const std::vector* items_; + OverItem over_item_; + bool recursive_; + + /** + * Index of the item we currently iterate over + */ + std::size_t index_; + + /** + * Iterator over the current item, built once we reached the item + */ + std::unique_ptr item_; + + /** + * Move on to the next items, and build their iterators, until one is not done. + */ + void SkipDoneItems() { + while (index_ < items_->size()) { + if (item_ == nullptr) { + item_ = over_item_((*items_)[index_], recursive_); + item_->Start(); + } + + if (!item_->Done()) { + return; + } + + item_ = nullptr; + ++index_; + } + } +}; // class EachIterator + +template +std::unique_ptr Each( + const std::vector& items, + std::unique_ptr (*over_item)(const T& item, bool recursive), + bool recursive +) { + return common::make_unique >(&items, over_item, recursive); +} + +/** + * \brief Iterate over the values of an instance, dispatched on its runtime type. + * + * Defined below, once all the classes have been covered. + */ +std::unique_ptr DispatchOnModelType( + const types::IClass& instance, + bool recursive +); + +/** + * \brief Iterate recursively over the values of an instance referenced from + * another instance. + * + * We dispatch on the runtime type of the instance only in \ref Start so that + * we descend into the instance only once the iteration reaches it. + */ +class DispatchingIterator : public IIterator { + public: + explicit DispatchingIterator( + const types::IClass* instance + ) : + instance_(instance) { + // Intentionally empty. + } + + DispatchingIterator(const DispatchingIterator& other) : + instance_(other.instance_), + child_(other.child_ == nullptr ? nullptr : other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_ = DispatchOnModelType(*instance_, true); + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + const types::IClass* instance_; + std::unique_ptr child_; +}; // class DispatchingIterator + +std::unique_ptr Over( + const types::IClass& instance, + bool recursive +) { + if (!recursive) { + // NOTE (mristin): + // In the non-recursive mode, we verify only the instance itself, but not + // the instances that it references. + return Empty(); + } + + return common::make_unique(&instance); +} + +template +std::unique_ptr ThroughPointer( + const std::shared_ptr& instance, + bool recursive +) { + return Over(*instance, recursive); +} + +using listOf_AbstractItem = std::vector< + std::shared_ptr +>; + +std::unique_ptr Over_listOf_AbstractItem( + const listOf_AbstractItem& value, + bool recursive +) { + if (!recursive) { + return Empty(); + } + + return Each( + value, + &ThroughPointer, + recursive + ); +} + +std::unique_ptr Over_AnotherItem( + const types::IAnotherItem& that, + bool +) { + return InProperty( + iteration::Property::kSerialNumber, + OneByValue(that.serial_number(), Shape::kINT64) + ); +} + std::unique_ptr Over_Something( const types::ISomething& that, - bool + bool recursive ) { - return One(&that, Shape::kSomething); + return Chain( + One(&that, Shape::kSomething), + InProperty( + iteration::Property::kSomeItems, + Over_listOf_AbstractItem(that.some_items(), recursive) + ) + ); } /** @@ -430,6 +974,11 @@ std::unique_ptr DispatchOnModelType( bool recursive ) { switch (instance.model_type()) { + case types::ModelType::kAnotherItem: + return Over_AnotherItem( + dynamic_cast(instance), + recursive + ); case types::ModelType::kSomething: return Over_Something( dynamic_cast(instance), diff --git a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/xmlization.cpp index 86c32b47c..fdcff79ff 100644 --- a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1238,6 +1239,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2298,6 +2307,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/test_jsonization_of_concrete_classes.cpp index ac5a8afdb..5aec53a66 100644 --- a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected SomeItem") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -437,16 +545,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .serial_number of AnotherItem" + "Test the safe integer range at .serial_number of AnotherItem" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "AnotherItem", @@ -456,11 +562,34 @@ TEST_CASE( instance->set_serial_number(value); - AssertSerializationFailsAt( - *instance, - ".serial_number" + AssertReportedEverywhere( + instance, + ".serial_number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "AnotherItem", + our::jsonization::AnotherItemFrom + ) + ); + + instance->set_serial_number(value); + + AssertNotReportedAnywhere(instance, ".serial_number"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/test_verification.cpp index 5bda6848d..0531ac830 100644 --- a/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/list_of_classes/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -320,5 +321,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/jsonization.cpp index 743ad9bdb..6b5a3fb58 100644 --- a/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1257,48 +1153,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/list_of_constrained_primitives/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/verification.cpp index d8068c40b..286c6e1b7 100644 --- a/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/verification.cpp @@ -287,8 +287,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/list_of_constrained_primitives/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/xmlization.cpp index 39c6a52dc..52b488fa4 100644 --- a/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/src/xmlization.cpp @@ -918,117 +918,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1780,18 +1669,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/list_of_constrained_primitives/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/jsonization.cpp index 708255c31..8a4f9d124 100644 --- a/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1326,48 +1222,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/list_of_enums/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/verification.cpp index 6212cfbec..5a325e981 100644 --- a/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/verification.cpp @@ -223,8 +223,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/list_of_enums/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/xmlization.cpp index de31dab9a..6339047e6 100644 --- a/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/list_of_enums/expected_output/src/xmlization.cpp @@ -918,117 +918,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1834,18 +1723,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/list_of_enums/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/list_of_enums/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/list_of_enums/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/list_of_enums/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/include/dummy/verification.hpp index 8ff07e305..6b779f0a8 100644 --- a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/jsonization.cpp index c2d834aac..43a523609 100644 --- a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1368,32 +1352,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/verification.cpp index 3d4d1c0ca..c1a2f0053 100644 --- a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool IsSmallPrime( @@ -173,7 +183,8 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - kSomething = 0 + kSomething = 0, + kINT64 = 1 }; // enum class Shape /** @@ -356,6 +367,8 @@ bool Something_11( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kSomething: { static const std::vector checks = { @@ -411,6 +424,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -423,19 +440,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -594,11 +672,315 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + +/** + * \brief Iterate over the values of every item of a list, one item after another. + * + * The iterator over an item is built only once the iteration reaches the item. + */ +template +class EachIterator : public IIterator { + public: + /** + * Build the iterator over the values of an item + */ + typedef std::unique_ptr (*OverItem)(const T& item, bool recursive); + + EachIterator( + const std::vector* items, + OverItem over_item, + bool recursive + ) : + items_(items), + over_item_(over_item), + recursive_(recursive), + index_(0) { + // Intentionally empty. + } + + EachIterator(const EachIterator& other) : + items_(other.items_), + over_item_(other.over_item_), + recursive_(other.recursive_), + index_(other.index_), + item_(other.item_ == nullptr ? nullptr : other.item_->Clone()) { + // Intentionally empty. + } + + void Start() override { + index_ = 0; + item_ = nullptr; + SkipDoneItems(); + } + + void Next() override { + item_->Next(); + SkipDoneItems(); + } + + bool Done() const override { + return index_ >= items_->size(); + } + + const void* Value() const override { + return item_->Value(); + } + + Shape ShapeOf() const override { + return item_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(index_) + ); + item_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + const std::vector* items_; + OverItem over_item_; + bool recursive_; + + /** + * Index of the item we currently iterate over + */ + std::size_t index_; + + /** + * Iterator over the current item, built once we reached the item + */ + std::unique_ptr item_; + + /** + * Move on to the next items, and build their iterators, until one is not done. + */ + void SkipDoneItems() { + while (index_ < items_->size()) { + if (item_ == nullptr) { + item_ = over_item_((*items_)[index_], recursive_); + item_->Start(); + } + + if (!item_->Done()) { + return; + } + + item_ = nullptr; + ++index_; + } + } +}; // class EachIterator + +template +std::unique_ptr Each( + const std::vector& items, + std::unique_ptr (*over_item)(const T& item, bool recursive), + bool recursive +) { + return common::make_unique >(&items, over_item, recursive); +} + +using listOf_int = std::vector; + +std::unique_ptr Over_int( + const int64_t& value, + bool +) { + return One(&value, Shape::kINT64); +} + +std::unique_ptr Over_listOf_int( + const listOf_int& value, + bool recursive +) { + return Each(value, &Over_int, recursive); +} + std::unique_ptr Over_Something( const types::ISomething& that, - bool + bool recursive ) { - return One(&that, Shape::kSomething); + return Chain( + One(&that, Shape::kSomething), + InProperty( + iteration::Property::kSomeInts, + Over_listOf_int(that.some_ints(), recursive) + ) + ); } /** diff --git a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/xmlization.cpp index de31ebda7..7884dc46f 100644 --- a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1015,6 +1016,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -1924,6 +1933,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/test_jsonization_of_concrete_classes.cpp index a2bfdcee2..c965299c3 100644 --- a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,6 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include + +#include #include @@ -212,6 +216,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -269,14 +379,15 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .some_ints[1] of Something" + "Test the serialization failure on a non-finite floating-point number " + "at .some_floats[1] of Something" ) { for ( - const int64_t value + const double value : { - 9007199254740992LL, - -9007199254740992LL + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() } ) { std::shared_ptr instance( @@ -286,32 +397,29 @@ TEST_CASE( ) ); - instance->set_some_ints( - std::vector{ - 0LL, + instance->set_some_floats( + std::vector{ + 0.0, value } ); AssertSerializationFailsAt( *instance, - ".some_ints[1]" + ".some_floats[1]" ); } } TEST_CASE( - "Test the serialization failure on a non-finite floating-point number " - "at .some_floats[1] of Something" + "Test the safe integer range at .some_ints[1] of Something" ) { for ( - const double value - : { - std::numeric_limits::infinity(), - -std::numeric_limits::infinity(), - std::numeric_limits::quiet_NaN() - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -319,18 +427,46 @@ TEST_CASE( ) ); - instance->set_some_floats( - std::vector{ - 0.0, + instance->set_some_ints( + std::vector{ + 0LL, value } ); - AssertSerializationFailsAt( - *instance, - ".some_floats[1]" + AssertReportedEverywhere( + instance, + ".some_ints[1]", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_some_ints( + std::vector{ + 0LL, + value + } + ); + + AssertNotReportedAnywhere(instance, ".some_ints[1]"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/list_of_primitives/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/method_references/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/method_references/expected_output/include/dummy/verification.hpp index a1bb702ef..893c52314 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/method_references/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/method_references/expected_output/src/jsonization.cpp index dde1265a3..cb8ac0dfc 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1367,32 +1351,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/method_references/expected_output/src/verification.cpp index 97913223d..32002fa49 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool IsDescribed( @@ -55,7 +65,8 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - kBox = 0 + kBox = 0, + kINT64 = 1 }; // enum class Shape /** @@ -90,6 +101,8 @@ bool Box_0( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kBox: { static const std::vector checks = { @@ -100,6 +113,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -112,19 +129,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -283,11 +361,248 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + std::unique_ptr Over_Box( const types::IBox& that, bool ) { - return One(&that, Shape::kBox); + return Chain( + One(&that, Shape::kBox), + InProperty( + iteration::Property::kSize, + OneByValue(that.size(), Shape::kINT64) + ) + ); } /** diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/method_references/expected_output/src/xmlization.cpp index ccbeb9816..d3daf7101 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1042,6 +1043,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -1617,6 +1626,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/method_references/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_jsonization_of_concrete_classes.cpp index 260e96a41..749264783 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Box") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -269,16 +377,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Box") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .size of Box" + "Test the safe integer range at .size of Box" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Box", @@ -288,11 +394,34 @@ TEST_CASE( instance->set_size(value); - AssertSerializationFailsAt( - *instance, - ".size" + AssertReportedEverywhere( + instance, + ".size", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Box", + our::jsonization::BoxFrom + ) + ); + + instance->set_size(value); + + AssertNotReportedAnywhere(instance, ".size"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_verification.cpp index 63946a9a0..66d89bf27 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Box") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_xmlization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_xmlization_of_concrete_classes.cpp index 9393d3054..902fb7c21 100644 --- a/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_xmlization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/method_references/expected_output/test/test_xmlization_of_concrete_classes.cpp @@ -192,5 +192,106 @@ TEST_CASE("Test the de-serialization failure on a duplicate property") { REQUIRE(!deserialized.has_value()); } +/** + * \brief Patch the first recorded example of IBox so that + * the content of the element \p xml_name is replaced by \p text. + */ +std::string PatchTheFirstExample( + const std::string& xml_name, + const std::string& text +) { + // NOTE (mristin): + // FindFilesBySuffixRecursively answers a deque, and it is already sorted, + // which is what the round-trip test above relies on as well. + const std::deque paths( + test::common::FindFilesBySuffixRecursively( + DetermineXmlDir() + / "Expected" + / "box", + ".xml" + ) + ); + + REQUIRE(!paths.empty()); + + const std::string original(test::common::MustReadString(paths.front())); + + const std::size_t start( + original.find(our::common::Concat("<", xml_name, ">")) + + xml_name.size() + 2 + ); + const std::size_t end( + original.find(our::common::Concat("")) + ); + + return original.substr(0, start) + text + original.substr(end); +} + +/** + * \brief Read the first recorded example of IBox with the content + * of the element \p xml_name replaced by \p text. + */ +std::shared_ptr ReadWith( + const std::string& xml_name, + const std::string& text +) { + const std::string patched(PatchTheFirstExample(xml_name, text)); + + std::istringstream iss(patched); + + our::common::expected< + std::shared_ptr, + our::xmlization::DeserializationError + > deserialized = our::xmlization::From(iss); + + INFO(our::common::Concat("De-serializing: ", patched)) + REQUIRE(deserialized.has_value()); + + std::shared_ptr casted( + std::dynamic_pointer_cast(*deserialized) + ); + REQUIRE(casted != nullptr); + + return casted; +} + +TEST_CASE("Refuse size outside the safe integer range") { + for ( + const std::string text : {"9007199254740992", "-9007199254740992"} + ) { + std::istringstream iss( + PatchTheFirstExample("size", text) + ); + + our::common::expected< + std::shared_ptr, + our::xmlization::DeserializationError + > deserialized = our::xmlization::From(iss); + + INFO(our::common::Concat("De-serializing ", text)) + REQUIRE(!deserialized.has_value()); + REQUIRE( + deserialized.error().cause + == L"Expected an integer, but got " + our::common::Utf8ToWstring(text) + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +} + +TEST_CASE("Read size at the bounds of the safe integer range") { + for ( + const int64_t value : {9007199254740991LL, -9007199254740991LL} + ) { + std::shared_ptr instance( + ReadWith( + "size", + std::to_string(value) + ) + ); + + REQUIRE(instance->size() == value); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/include/dummy/jsonization.hpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/include/dummy/jsonization.hpp index b4a3f82df..f307d8ed8 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/include/dummy/jsonization.hpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/include/dummy/jsonization.hpp @@ -220,6 +220,63 @@ common::expected< bool additional_properties = false ); +/** + * \brief Deserialize \p json as a JSON-able value. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able array. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able object. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +); + // endregion Deserialization // region Serialization @@ -259,6 +316,19 @@ nlohmann::json Serialize( const types::IClass& that ); +/** + * \brief Serialize the JSON-able value \p value to a JSON value. + * + * \param value JSON-able value to be serialized + * \return a copy of \p value + * \throw \ref SerializationException if \p value contains, at any depth, + * a binary or a discarded value, an infinity, a not-a-number or an integer + * outside the safe integer range [-2^53 + 1, 2^53 - 1] + */ +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +); + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/json_value_verification.cpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/json_value_verification.cpp index 893e09045..2ac65419a 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/json_value_verification.cpp @@ -13,6 +13,80 @@ namespace dummy { namespace verification { +namespace { +/** + * \brief Describe that the \p integer of a JSON-able value lies outside + * the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeJsonAbleIntegerOutsideSafeRange( + const std::wstring& integer +) { + return common::Concat( + L"Expected a JSON-able value, but got the integer ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace + +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * \param json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +) { + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_float: { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number, so we refuse both + // instead of silently writing them out as ``null``. The caller gives us + // an ``nlohmann::json`` which may well have been constructed + // programmatically, so a non-finite number can reach us even though no + // conformant parser would ever produce one. + const double number = json.get(); + if (!std::isfinite(number)) { + return common::Concat( + L"Expected a JSON-able value, but got the number ", + std::to_wstring(number), + L", which is neither finite nor representable in JSON" + ); + } + return common::nullopt; + } + + default: + return common::nullopt; + } +} + namespace { /** @@ -34,19 +108,8 @@ common::optional CheckShallowly( ); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though ``nlohmann::json`` will happily hold - // one if it was constructed programmatically. This mirrors what the - // jsonization refuses to serialize. - if (value.is_number_float() && !std::isfinite(value.get())) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" - ) - ); + if (value.is_number()) { + return CheckJsonAbleNumber(value); } return common::nullopt; diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/json_value_verification.hpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/json_value_verification.hpp index 42c9230f8..f91769fe4 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/json_value_verification.hpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/json_value_verification.hpp @@ -18,6 +18,24 @@ namespace dummy { namespace verification { +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * 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 json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +); + /** * Specify the shape that the top-level JSON value itself is expected to * have, ``kAny`` if unconstrained. diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/jsonization.cpp index c231c2f75..3866a3d32 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/jsonization.cpp @@ -4,6 +4,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" #include "dummy/wstringification.hpp" +#include "json_value_verification.hpp" #pragma warning(push, 0) #include @@ -302,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 @@ -767,27 +664,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``DeserializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (json.is_number_float() && !std::isfinite(json.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(json.get()), - L", which is neither finite nor representable in JSON" + if (json.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(json); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -2173,6 +2062,75 @@ common::expected< ); } +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonValue(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonArray(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonObject(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + common::expected< std::shared_ptr, DeserializationError @@ -2331,48 +2289,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. * @@ -2745,7 +2661,7 @@ const T& Deref(const T& value) { std::pair< common::optional, common::optional -> SerializeJsonValue( +> TrySerializeJsonValue( const nlohmann::json& value ) { if (value.is_binary() || value.is_discarded()) { @@ -2769,7 +2685,7 @@ std::pair< for (const nlohmann::json& item : value) { common::optional serialized_item; common::optional error; - std::tie(serialized_item, error) = SerializeJsonValue(item); + std::tie(serialized_item, error) = TrySerializeJsonValue(item); if (error.has_value()) { error->path.segments.emplace_front( @@ -2792,7 +2708,7 @@ std::pair< for (const auto& item : value.items()) { common::optional serialized_value; common::optional error; - std::tie(serialized_value, error) = SerializeJsonValue(item.value()); + std::tie(serialized_value, error) = TrySerializeJsonValue(item.value()); if (error.has_value()) { error->path.segments.emplace_front( @@ -2810,27 +2726,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``SerializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (value.is_number_float() && !std::isfinite(value.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" + if (value.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(value); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -2842,7 +2750,7 @@ std::pair< std::pair< common::optional, common::optional -> SerializeJsonArray( +> TrySerializeJsonArray( const nlohmann::json& value ) { if (!value.is_array()) { @@ -2857,13 +2765,13 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } std::pair< common::optional, common::optional -> SerializeJsonObject( +> TrySerializeJsonObject( const nlohmann::json& value ) { if (!value.is_object()) { @@ -2878,7 +2786,7 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } /** @@ -3114,7 +3022,7 @@ std::pair< iteration::Property::kMappings, SerializeListWithFallible( that.mappings(), - SerializeJsonObject + TrySerializeJsonObject ) ); if (error.has_value()) { @@ -3224,6 +3132,27 @@ nlohmann::json Serialize( return std::move(*result); } +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +) { + common::optional result; + common::optional error; + + std::tie( + result, + error + ) = TrySerializeJsonValue(value); + + if (error.has_value()) { + throw SerializationException( + std::move(error->cause), + std::move(error->path) + ); + } + + return std::move(*result); +} + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/verification.cpp index 941cb30cb..67f3e17a2 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/verification.cpp @@ -216,7 +216,7 @@ const std::vector& ChecksOf(Shape shape) { case Shape::kJSONArray: case Shape::kJSONObject: // NOTE (mristin): - // The JSON-able values are verified by the nested verification. + // These values are verified by the nested verification. return kNoChecks; default: throw std::logic_error( diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/xml_rpc.cpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/xml_rpc.cpp index abf1d37d3..5d8c4b8a6 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/xml_rpc.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "xml_rpc.hpp" +#include "json_value_verification.hpp" #include "xml_common.hpp" #pragma warning(push, 0) @@ -902,23 +903,21 @@ common::optional SerializeValueBodyTo( writer.SerializeString(value.get() ? "1" : "0"); writer.StopElement("boolean"); } else if (value.is_number()) { - const double number = value.get(); - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value. ``SelfClosingWriter::SerializeDouble`` would - // happily write "INF" or "NaN" -- the xs:double spellings -- which + // We check the number exactly as the verification and the JSON + // de/serialization do. In particular, JSON knows neither an infinity nor + // a not-a-number, and ``SelfClosingWriter::SerializeDouble`` would happily + // write "INF" or "NaN" -- the xs:double spellings -- which // ``DeserializeDoubleBodyFrom`` deliberately refuses to read back. - if (!std::isfinite(number)) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(number), - L", which is neither finite nor representable in JSON" - ) - ); + common::optional cause( + verification::CheckJsonAbleNumber(value) + ); + if (cause.has_value()) { + return common::make_optional(std::move(*cause)); } + const double number = value.get(); + writer.StartElementInNoNamespace( "double", undeclare_namespace ); diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/xmlization.cpp index abe35b422..0e98ab2eb 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/src/xmlization.cpp @@ -1135,117 +1135,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -2797,18 +2686,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_json_value_verification.cpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_json_value_verification.cpp index 64626bf8a..7b55f07c3 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_json_value_verification.cpp @@ -7,6 +7,7 @@ * of any particular meta-model. */ +#include "dummy/jsonization.hpp" #include "dummy/verification.hpp" #include "json_value_verification.hpp" @@ -15,6 +16,7 @@ #include #include +#include #include #pragma warning(pop) @@ -248,5 +250,57 @@ TEST_CASE("Reject a value whose top-level shape does not match kObject") { REQUIRE(errors.size() == 1); } +TEST_CASE("Reject an integer just outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + const nlohmann::json& value = a_case.first; + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + const std::vector errors(CollectErrors(verificator)); + REQUIRE(errors.size() == 1); + REQUIRE( + errors[0].cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + + REQUIRE(!our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE_THROWS_AS( + our::jsonization::SerializeJsonValue(value), + our::jsonization::SerializationException + ); + } +} + +TEST_CASE("Accept the integers at the bounds of the safe integer range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + const nlohmann::json value(integer); + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + REQUIRE(CollectErrors(verificator).empty()); + + REQUIRE(our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE(our::jsonization::SerializeJsonValue(value) == value); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_verification.cpp index ebe5bb334..33c1cf09c 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -368,5 +369,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_xml_rpc.cpp b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_xml_rpc.cpp index 89bda2904..82937fdcd 100644 --- a/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/naming_collisions/expected_output/test/test_xml_rpc.cpp @@ -16,7 +16,10 @@ #include #include #include +#include #include +#include +#include #pragma warning(pop) #include @@ -93,6 +96,42 @@ TEST_CASE("Serialize rejects a non-finite number") { } } +TEST_CASE("Serialize rejects an integer outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, a_case.first) + ); + REQUIRE(error.has_value()); + REQUIRE( + error->cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +} + +TEST_CASE("Serialize accepts the integers at the bounds of the safe range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, nlohmann::json(integer)) + ); + REQUIRE(!error.has_value()); + } +} + TEST_CASE("Deserialize rejects a non-finite number") { // NOTE (mristin): // The xs:double spellings of the three special values are deliberately diff --git a/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/jsonization.cpp index bcbc61873..6940e9607 100644 --- a/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -2386,48 +2282,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/nested_any_in_all/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/verification.cpp index 5ee0ee8cd..807b6258b 100644 --- a/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/verification.cpp @@ -213,8 +213,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/nested_any_in_all/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/xmlization.cpp index ea5db6d32..492687753 100644 --- a/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/src/xmlization.cpp @@ -1237,117 +1237,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -2603,18 +2492,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/nested_any_in_all/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/nested_collections/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/nested_collections/expected_output/include/dummy/common.hpp index 079054f03..5d51b79d4 100644 --- a/dev/test_data/main/cpp/expected/nested_collections/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/nested_collections/expected_output/include/dummy/common.hpp @@ -10146,6 +10146,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + /** * \brief Compare \p that text and \p other text code point by code point. * diff --git a/dev/test_data/main/cpp/expected/nested_collections/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/nested_collections/expected_output/include/dummy/verification.hpp index aedebe530..cf796e202 100644 --- a/dev/test_data/main/cpp/expected/nested_collections/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/nested_collections/expected_output/include/dummy/verification.hpp @@ -134,6 +134,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; @@ -254,7 +266,8 @@ bool ResetCorner( // region Verification of constrained primitives /** - * \brief Verify that the invariants hold for \p that value. + * \brief Verify that the invariants hold for \p that value, and that + * it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. * * \param that value to be verified * \return Iterable over constraint violations diff --git a/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/common.cpp index f6be079e7..c5f9a3730 100644 --- a/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/common.cpp @@ -14120,6 +14120,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + namespace { /** diff --git a/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/jsonization.cpp index 1568be190..1ff374e27 100644 --- a/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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 - ) - ); - } + int64_t value = 0; + common::optional cause; - // 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. + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -2859,32 +2843,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/verification.cpp index 3e76ffd8e..0dd019bfc 100644 --- a/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool GridIsRectangular( @@ -136,7 +146,8 @@ enum class Shape : std::uint32_t { kPositiveInt = 0, kCode = 1, kSomeItem = 2, - kSomething = 3 + kSomething = 3, + kINT64 = 4 }; // enum class Shape /** @@ -302,6 +313,8 @@ bool Something_8( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kPositiveInt: { static const std::vector checks = { @@ -371,6 +384,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -383,19 +400,81 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kPositiveInt: + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -1048,6 +1127,12 @@ std::unique_ptr ThroughPointer( return Over(*instance, recursive); } +using listOf_int = std::vector; + +using listOf_listOf_int = std::vector< + std::vector +>; + using tupleOf2_PositiveInt_setOf_str = std::tuple< int64_t, std::unordered_set @@ -1101,6 +1186,27 @@ using listOf_listOf_AbstractItem = std::vector< > >; +std::unique_ptr Over_int( + const int64_t& value, + bool +) { + return One(&value, Shape::kINT64); +} + +std::unique_ptr Over_listOf_int( + const listOf_int& value, + bool recursive +) { + return Each(value, &Over_int, recursive); +} + +std::unique_ptr Over_listOf_listOf_int( + const listOf_listOf_int& value, + bool recursive +) { + return Each(value, &Over_listOf_int, recursive); +} + std::unique_ptr Over_tupleOf2_PositiveInt_setOf_str( const tupleOf2_PositiveInt_setOf_str& value, bool @@ -1145,7 +1251,10 @@ std::unique_ptr Over_tupleOf2_int_Code( const tupleOf2_int_Code& value, bool ) { - return AtIndex(1, One(&std::get<1>(value), Shape::kCode)); + return Chain( + AtIndex(0, One(&std::get<0>(value), Shape::kINT64)), + AtIndex(1, One(&std::get<1>(value), Shape::kCode)) + ); } std::unique_ptr Over_tupleOf3_listOf_AbstractItem_setOf_Color_tupleOf2_int_Code( @@ -1218,12 +1327,26 @@ std::unique_ptr Over_SomeItem( return One(&that, Shape::kSomeItem); } +std::unique_ptr Over_AnotherItem( + const types::IAnotherItem& that, + bool +) { + return InProperty( + iteration::Property::kSerialNumber, + OneByValue(that.serial_number(), Shape::kINT64) + ); +} + std::unique_ptr Over_Something( const types::ISomething& that, bool recursive ) { return Chain( One(&that, Shape::kSomething), + InProperty( + iteration::Property::kGrid, + Over_listOf_listOf_int(that.grid(), recursive) + ), InProperty( iteration::Property::kRowsOfPairs, Over_listOf_listOf_tupleOf2_PositiveInt_setOf_str( @@ -1270,6 +1393,11 @@ std::unique_ptr DispatchOnModelType( dynamic_cast(instance), recursive ); + case types::ModelType::kAnotherItem: + return Over_AnotherItem( + dynamic_cast(instance), + recursive + ); case types::ModelType::kSomething: return Over_Something( dynamic_cast(instance), diff --git a/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/xmlization.cpp index 35cc6e4b2..9b14508f9 100644 --- a/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/nested_collections/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1296,6 +1297,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -3292,6 +3301,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/test_jsonization_of_concrete_classes.cpp index 8370246fc..81b236072 100644 --- a/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected SomeItem") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -381,16 +489,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .serial_number of AnotherItem" + "Test the safe integer range at .serial_number of AnotherItem" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "AnotherItem", @@ -400,11 +506,34 @@ TEST_CASE( instance->set_serial_number(value); - AssertSerializationFailsAt( - *instance, - ".serial_number" + AssertReportedEverywhere( + instance, + ".serial_number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "AnotherItem", + our::jsonization::AnotherItemFrom + ) + ); + + instance->set_serial_number(value); + + AssertNotReportedAnywhere(instance, ".serial_number"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/test_verification.cpp index eaed1c972..70edbd94a 100644 --- a/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/nested_collections/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -272,5 +273,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/include/dummy/verification.hpp index fc3171327..e17187826 100644 --- a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/jsonization.cpp index 0d0750588..e97a884fe 100644 --- a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1580,32 +1564,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/verification.cpp index 9ba66dd37..1002dcd88 100644 --- a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool TextIsShort( @@ -147,7 +157,8 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - kSomething = 0 + kSomething = 0, + kINT64 = 1 }; // enum class Shape /** @@ -250,6 +261,8 @@ bool Something_6( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kSomething: { static const std::vector checks = { @@ -284,6 +297,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -296,19 +313,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -467,11 +545,189 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + std::unique_ptr Over_Something( const types::ISomething& that, bool ) { - return One(&that, Shape::kSomething); + return Chain( + One(&that, Shape::kSomething), + InProperty( + iteration::Property::kOptionalNumber, + that.optional_number().has_value() + ? One(&(*that.optional_number()), Shape::kINT64) + : Empty() + ) + ); } /** diff --git a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/xmlization.cpp index 79ae6b003..1f591dbd5 100644 --- a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1087,6 +1088,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2107,6 +2116,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/test/test_verification.cpp index f840a78a5..74be5fd36 100644 --- a/dev/test_data/main/cpp/expected/optional_arguments/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/optional_arguments/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -224,5 +225,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/include/dummy/common.hpp index 6336e407c..fc3a01e72 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/include/dummy/common.hpp @@ -48,6 +48,10 @@ #pragma warning(pop) #endif +#pragma warning(push, 0) +#include +#pragma warning(pop) + namespace dummy { /** @@ -10100,6 +10104,43 @@ size_t LenTuple(const std::tuple&) { return sizeof...(T); } +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/include/dummy/jsonization.hpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/include/dummy/jsonization.hpp index f955d7b3b..e2e75e791 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/include/dummy/jsonization.hpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/include/dummy/jsonization.hpp @@ -169,6 +169,63 @@ common::expected< bool additional_properties = false ); +/** + * \brief Deserialize \p json as a JSON-able value. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able array. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +); + +/** + * \brief Deserialize \p json as a JSON-able object. + * + * A JSON-able value is, recursively, exactly as JSON itself is defined: + * a 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. + * + * \param json value to be de-serialized + * \return a copy of \p json, or a de-serialization error, if any. + */ +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +); + // endregion Deserialization // region Serialization @@ -208,6 +265,19 @@ nlohmann::json Serialize( const types::IClass& that ); +/** + * \brief Serialize the JSON-able value \p value to a JSON value. + * + * \param value JSON-able value to be serialized + * \return a copy of \p value + * \throw \ref SerializationException if \p value contains, at any depth, + * a binary or a discarded value, an infinity, a not-a-number or an integer + * outside the safe integer range [-2^53 + 1, 2^53 - 1] + */ +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +); + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/common.cpp index fbfe76815..f5900c9f3 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/common.cpp @@ -14060,6 +14060,26 @@ std::wstring Utf8ToWstring(const std::string& utf8_text) { return Utf8ToWstring(&(utf8_text[0]), utf8_text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/json_value_verification.cpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/json_value_verification.cpp index 893e09045..2ac65419a 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/json_value_verification.cpp @@ -13,6 +13,80 @@ namespace dummy { namespace verification { +namespace { +/** + * \brief Describe that the \p integer of a JSON-able value lies outside + * the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeJsonAbleIntegerOutsideSafeRange( + const std::wstring& integer +) { + return common::Concat( + L"Expected a JSON-able value, but got the integer ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace + +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * \param json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +) { + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (!common::IsSafeInteger(integer)) { + return DescribeJsonAbleIntegerOutsideSafeRange(std::to_wstring(integer)); + } + return common::nullopt; + } + + case nlohmann::json::value_t::number_float: { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number, so we refuse both + // instead of silently writing them out as ``null``. The caller gives us + // an ``nlohmann::json`` which may well have been constructed + // programmatically, so a non-finite number can reach us even though no + // conformant parser would ever produce one. + const double number = json.get(); + if (!std::isfinite(number)) { + return common::Concat( + L"Expected a JSON-able value, but got the number ", + std::to_wstring(number), + L", which is neither finite nor representable in JSON" + ); + } + return common::nullopt; + } + + default: + return common::nullopt; + } +} + namespace { /** @@ -34,19 +108,8 @@ common::optional CheckShallowly( ); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though ``nlohmann::json`` will happily hold - // one if it was constructed programmatically. This mirrors what the - // jsonization refuses to serialize. - if (value.is_number_float() && !std::isfinite(value.get())) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" - ) - ); + if (value.is_number()) { + return CheckJsonAbleNumber(value); } return common::nullopt; diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/json_value_verification.hpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/json_value_verification.hpp index 42c9230f8..f91769fe4 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/json_value_verification.hpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/json_value_verification.hpp @@ -18,6 +18,24 @@ namespace dummy { namespace verification { +/** + * \brief Check that the number \p json is a number of a JSON-able value. + * + * JSON knows a single numeric type, which all the other SDKs read into + * a 64-bit floating-point number. 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. + * + * 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 json number to be checked + * \return the cause why \p json is not JSON-able, if it is not + */ +common::optional CheckJsonAbleNumber( + const nlohmann::json& json +); + /** * Specify the shape that the top-level JSON value itself is expected to * have, ``kAny`` if unconstrained. diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/jsonization.cpp index c97d7549d..319f2340d 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/jsonization.cpp @@ -4,6 +4,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" #include "dummy/wstringification.hpp" +#include "json_value_verification.hpp" #pragma warning(push, 0) #include @@ -302,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 @@ -767,27 +664,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``DeserializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (json.is_number_float() && !std::isfinite(json.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(json.get()), - L", which is neither finite nor representable in JSON" + if (json.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(json); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -1495,6 +1384,75 @@ common::expected< ); } +common::expected< + nlohmann::json, + DeserializationError +> JsonValueFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonValue(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonArrayFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonArray(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + +common::expected< + nlohmann::json, + DeserializationError +> JsonObjectFrom( + const nlohmann::json& json +) { + common::optional value; + common::optional error; + + std::tie( + value, + error + ) = DeserializeJsonObject(json); + + if (error.has_value()) { + return common::make_unexpected( + std::move(*error) + ); + } + + return std::move(*value); +} + common::expected< std::shared_ptr, DeserializationError @@ -1605,48 +1563,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. * @@ -1996,7 +1912,7 @@ inline std::pair< std::pair< common::optional, common::optional -> SerializeJsonValue( +> TrySerializeJsonValue( const nlohmann::json& value ) { if (value.is_binary() || value.is_discarded()) { @@ -2020,7 +1936,7 @@ std::pair< for (const nlohmann::json& item : value) { common::optional serialized_item; common::optional error; - std::tie(serialized_item, error) = SerializeJsonValue(item); + std::tie(serialized_item, error) = TrySerializeJsonValue(item); if (error.has_value()) { error->path.segments.emplace_front( @@ -2043,7 +1959,7 @@ std::pair< for (const auto& item : value.items()) { common::optional serialized_value; common::optional error; - std::tie(serialized_value, error) = SerializeJsonValue(item.value()); + std::tie(serialized_value, error) = TrySerializeJsonValue(item.value()); if (error.has_value()) { error->path.segments.emplace_front( @@ -2061,27 +1977,19 @@ std::pair< return std::make_pair(std::move(result), common::nullopt); } - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so we refuse both - // instead of silently writing them out as ``null``, mirroring - // ``SerializeDouble`` for the floating-point properties of the model. - // The caller gives us an ``nlohmann::json`` which may well have been - // constructed programmatically, so a non-finite number can reach us - // even though no conformant parser would ever produce one. - if (value.is_number_float() && !std::isfinite(value.get())) { - return std::make_pair< - common::optional, - common::optional - >( - common::nullopt, - common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(value.get()), - L", which is neither finite nor representable in JSON" + if (value.is_number()) { + common::optional cause = verification::CheckJsonAbleNumber(value); + if (cause.has_value()) { + return std::make_pair< + common::optional, + common::optional + >( + common::nullopt, + common::make_optional( + std::move(*cause) ) - ) - ); + ); + } } // NOTE (mristin): @@ -2093,7 +2001,7 @@ std::pair< std::pair< common::optional, common::optional -> SerializeJsonArray( +> TrySerializeJsonArray( const nlohmann::json& value ) { if (!value.is_array()) { @@ -2108,13 +2016,13 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } std::pair< common::optional, common::optional -> SerializeJsonObject( +> TrySerializeJsonObject( const nlohmann::json& value ) { if (!value.is_object()) { @@ -2129,7 +2037,7 @@ std::pair< ); } - return SerializeJsonValue(value); + return TrySerializeJsonValue(value); } /** @@ -2229,7 +2137,7 @@ std::pair< iteration::Property::kOptionalValues, SerializeListWithFallible( *maybe_optional_values, - SerializeJsonValue + TrySerializeJsonValue ) ); if (error.has_value()) { @@ -2301,6 +2209,27 @@ nlohmann::json Serialize( return std::move(*result); } +nlohmann::json SerializeJsonValue( + const nlohmann::json& value +) { + common::optional result; + common::optional error; + + std::tie( + result, + error + ) = TrySerializeJsonValue(value); + + if (error.has_value()) { + throw SerializationException( + std::move(error->cause), + std::move(error->path) + ); + } + + return std::move(*result); +} + // endregion Serialization } // namespace jsonization diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/verification.cpp index fe48ed36c..a99cec841 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/verification.cpp @@ -82,7 +82,7 @@ const std::vector& ChecksOf(Shape shape) { case Shape::kJSONArray: case Shape::kJSONObject: // NOTE (mristin): - // The JSON-able values are verified by the nested verification. + // These values are verified by the nested verification. return kNoChecks; default: throw std::logic_error( diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/xml_rpc.cpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/xml_rpc.cpp index abf1d37d3..5d8c4b8a6 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/xml_rpc.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "xml_rpc.hpp" +#include "json_value_verification.hpp" #include "xml_common.hpp" #pragma warning(push, 0) @@ -902,23 +903,21 @@ common::optional SerializeValueBodyTo( writer.SerializeString(value.get() ? "1" : "0"); writer.StopElement("boolean"); } else if (value.is_number()) { - const double number = value.get(); - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value. ``SelfClosingWriter::SerializeDouble`` would - // happily write "INF" or "NaN" -- the xs:double spellings -- which + // We check the number exactly as the verification and the JSON + // de/serialization do. In particular, JSON knows neither an infinity nor + // a not-a-number, and ``SelfClosingWriter::SerializeDouble`` would happily + // write "INF" or "NaN" -- the xs:double spellings -- which // ``DeserializeDoubleBodyFrom`` deliberately refuses to read back. - if (!std::isfinite(number)) { - return common::make_optional( - common::Concat( - L"Expected a JSON-able value, but got the number ", - std::to_wstring(number), - L", which is neither finite nor representable in JSON" - ) - ); + common::optional cause( + verification::CheckJsonAbleNumber(value) + ); + if (cause.has_value()) { + return common::make_optional(std::move(*cause)); } + const double number = value.get(); + writer.StartElementInNoNamespace( "double", undeclare_namespace ); diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/xmlization.cpp index 81bf30856..28d153a5a 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/src/xmlization.cpp @@ -991,117 +991,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -2090,18 +1979,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_json_value_verification.cpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_json_value_verification.cpp index 64626bf8a..7b55f07c3 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_json_value_verification.cpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_json_value_verification.cpp @@ -7,6 +7,7 @@ * of any particular meta-model. */ +#include "dummy/jsonization.hpp" #include "dummy/verification.hpp" #include "json_value_verification.hpp" @@ -15,6 +16,7 @@ #include #include +#include #include #pragma warning(pop) @@ -248,5 +250,57 @@ TEST_CASE("Reject a value whose top-level shape does not match kObject") { REQUIRE(errors.size() == 1); } +TEST_CASE("Reject an integer just outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + const nlohmann::json& value = a_case.first; + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + const std::vector errors(CollectErrors(verificator)); + REQUIRE(errors.size() == 1); + REQUIRE( + errors[0].cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + + REQUIRE(!our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE_THROWS_AS( + our::jsonization::SerializeJsonValue(value), + our::jsonization::SerializationException + ); + } +} + +TEST_CASE("Accept the integers at the bounds of the safe integer range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + const nlohmann::json value(integer); + + verification::JsonValueVerificator verificator( + value, verification::JsonValueShape::kAny + ); + + REQUIRE(CollectErrors(verificator).empty()); + + REQUIRE(our::jsonization::JsonValueFrom(value).has_value()); + + REQUIRE(our::jsonization::SerializeJsonValue(value) == value); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_verification.cpp index f840a78a5..74be5fd36 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -224,5 +225,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_xml_rpc.cpp b/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_xml_rpc.cpp index 89bda2904..82937fdcd 100644 --- a/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_xml_rpc.cpp +++ b/dev/test_data/main/cpp/expected/optional_lists/expected_output/test/test_xml_rpc.cpp @@ -16,7 +16,10 @@ #include #include #include +#include #include +#include +#include #pragma warning(pop) #include @@ -93,6 +96,42 @@ TEST_CASE("Serialize rejects a non-finite number") { } } +TEST_CASE("Serialize rejects an integer outside the safe integer range") { + // NOTE (mristin): + // We construct the values programmatically, so that we cover both + // the signed and the unsigned integers of ``nlohmann::json``. + const std::vector > cases = { + {nlohmann::json(int64_t(9007199254740992LL)), L"9007199254740992"}, + {nlohmann::json(int64_t(-9007199254740992LL)), L"-9007199254740992"}, + {nlohmann::json(uint64_t(9007199254740992ULL)), L"9007199254740992"} + }; + + for (const std::pair& a_case : cases) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, a_case.first) + ); + REQUIRE(error.has_value()); + REQUIRE( + error->cause + == L"Expected a JSON-able value, but got the integer " + a_case.second + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +} + +TEST_CASE("Serialize accepts the integers at the bounds of the safe range") { + for ( + const int64_t integer : {9007199254740991LL, -9007199254740991LL} + ) { + std::ostringstream oss; + common::optional error( + xml_rpc::SerializeValue(oss, nlohmann::json(integer)) + ); + REQUIRE(!error.has_value()); + } +} + TEST_CASE("Deserialize rejects a non-finite number") { // NOTE (mristin): // The xs:double spellings of the three special values are deliberately diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/include/dummy/common.hpp index 6336e407c..fc3a01e72 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/include/dummy/common.hpp @@ -48,6 +48,10 @@ #pragma warning(pop) #endif +#pragma warning(push, 0) +#include +#pragma warning(pop) + namespace dummy { /** @@ -10100,6 +10104,43 @@ size_t LenTuple(const std::tuple&) { return sizeof...(T); } +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/include/dummy/verification.hpp index 0dfe6262e..5340569be 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/common.cpp index fbfe76815..f5900c9f3 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/common.cpp @@ -14060,6 +14060,26 @@ std::wstring Utf8ToWstring(const std::string& utf8_text) { return Utf8ToWstring(&(utf8_text[0]), utf8_text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/jsonization.cpp index fd9aa0dae..63ab0870c 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1237,32 +1221,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/verification.cpp index 40ac2d582..37f616bcd 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + namespace { /** @@ -45,7 +55,7 @@ namespace { * A shape tells which checks apply to a value, see \ref ChecksOf. */ enum class Shape : std::uint32_t { - + kINT64 = 0 }; // enum class Shape /** @@ -71,7 +81,13 @@ struct Check { * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -84,19 +100,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -199,16 +276,158 @@ std::unique_ptr Empty() { return common::make_unique(); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + +std::unique_ptr Over_Something( + const types::ISomething& that, + bool +) { + return InProperty( + iteration::Property::kSomeInt, + OneByValue(that.some_int(), Shape::kINT64) + ); +} + /** * Iterate over the values of the \p instance, dispatched on its runtime type. */ std::unique_ptr DispatchOnModelType( - const types::IClass&, - bool + const types::IClass& instance, + bool recursive ) { - // NOTE (mristin): - // The instances of no class have anything to verify. - return Empty(); + switch (instance.model_type()) { + case types::ModelType::kSomething: + return Over_Something( + dynamic_cast(instance), + recursive + ); + default: + // NOTE (mristin): + // The instances of the other classes have nothing to verify. + return Empty(); + } } // endregion Iteration over the values diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/xmlization.cpp index 7f0cc55e1..b9d8eca7a 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1015,6 +1016,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -1637,6 +1646,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_jsonization_of_concrete_classes.cpp index cae1ca597..ebc93c079 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,6 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include + +#include #include @@ -212,6 +216,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -269,14 +379,15 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .some_int of Something" + "Test the serialization failure on a non-finite floating-point number " + "at .some_float of Something" ) { for ( - const int64_t value + const double value : { - 9007199254740992LL, - -9007199254740992LL + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() } ) { std::shared_ptr instance( @@ -286,27 +397,24 @@ TEST_CASE( ) ); - instance->set_some_int(value); + instance->set_some_float(value); AssertSerializationFailsAt( *instance, - ".some_int" + ".some_float" ); } } TEST_CASE( - "Test the serialization failure on a non-finite floating-point number " - "at .some_float of Something" + "Test the safe integer range at .some_int of Something" ) { for ( - const double value - : { - std::numeric_limits::infinity(), - -std::numeric_limits::infinity(), - std::numeric_limits::quiet_NaN() - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -314,12 +422,35 @@ TEST_CASE( ) ); - instance->set_some_float(value); + instance->set_some_int(value); - AssertSerializationFailsAt( - *instance, - ".some_float" + AssertReportedEverywhere( + instance, + ".some_int", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + instance->set_some_int(value); + + AssertNotReportedAnywhere(instance, ".some_int"); } } diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_xmlization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_xmlization_of_concrete_classes.cpp index 21e05e288..f19e653a4 100644 --- a/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_xmlization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/primitive_types/expected_output/test/test_xmlization_of_concrete_classes.cpp @@ -193,10 +193,10 @@ TEST_CASE("Test the de-serialization failure on a duplicate property") { } /** - * \brief Read the first recorded example of ISomething with the content - * of the element \p xml_name replaced by \p text. + * \brief Patch the first recorded example of ISomething so that + * the content of the element \p xml_name is replaced by \p text. */ -std::shared_ptr ReadWith( +std::string PatchTheFirstExample( const std::string& xml_name, const std::string& text ) { @@ -224,9 +224,18 @@ std::shared_ptr ReadWith( original.find(our::common::Concat("")) ); - const std::string patched( - original.substr(0, start) + text + original.substr(end) - ); + return original.substr(0, start) + text + original.substr(end); +} + +/** + * \brief Read the first recorded example of ISomething with the content + * of the element \p xml_name replaced by \p text. + */ +std::shared_ptr ReadWith( + const std::string& xml_name, + const std::string& text +) { + const std::string patched(PatchTheFirstExample(xml_name, text)); std::istringstream iss(patched); @@ -400,5 +409,43 @@ TEST_CASE("Read someBytes from ") { REQUIRE(instance->some_bytes() == std::vector{}); } +TEST_CASE("Refuse someInt outside the safe integer range") { + for ( + const std::string text : {"9007199254740992", "-9007199254740992"} + ) { + std::istringstream iss( + PatchTheFirstExample("someInt", text) + ); + + our::common::expected< + std::shared_ptr, + our::xmlization::DeserializationError + > deserialized = our::xmlization::From(iss); + + INFO(our::common::Concat("De-serializing ", text)) + REQUIRE(!deserialized.has_value()); + REQUIRE( + deserialized.error().cause + == L"Expected an integer, but got " + our::common::Utf8ToWstring(text) + + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +} + +TEST_CASE("Read someInt at the bounds of the safe integer range") { + for ( + const int64_t value : {9007199254740991LL, -9007199254740991LL} + ) { + std::shared_ptr instance( + ReadWith( + "someInt", + std::to_string(value) + ) + ); + + REQUIRE(instance->some_int() == value); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/jsonization.cpp index 9552950cb..393b40bbc 100644 --- a/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1213,48 +1109,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/problematic_keywords/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/verification.cpp index 560252429..dcbc902c1 100644 --- a/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/verification.cpp @@ -110,8 +110,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/problematic_keywords/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/xmlization.cpp index 60109c5c8..28eee8c66 100644 --- a/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/src/xmlization.cpp @@ -918,117 +918,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1612,18 +1501,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/problematic_keywords/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/sets/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/sets/expected_output/include/dummy/common.hpp index 1d77e9c3f..a892afa0d 100644 --- a/dev/test_data/main/cpp/expected/sets/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/sets/expected_output/include/dummy/common.hpp @@ -10119,6 +10119,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + /** * \brief Give a new set of the items which are both in \p that and * in \p other. diff --git a/dev/test_data/main/cpp/expected/sets/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/sets/expected_output/include/dummy/verification.hpp index e7247ce0d..fbb02a139 100644 --- a/dev/test_data/main/cpp/expected/sets/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/sets/expected_output/include/dummy/verification.hpp @@ -134,6 +134,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/sets/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/sets/expected_output/src/common.cpp index f6be079e7..c5f9a3730 100644 --- a/dev/test_data/main/cpp/expected/sets/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/sets/expected_output/src/common.cpp @@ -14120,6 +14120,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + namespace { /** diff --git a/dev/test_data/main/cpp/expected/sets/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/sets/expected_output/src/jsonization.cpp index 24d847d39..71d4e07ce 100644 --- a/dev/test_data/main/cpp/expected/sets/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/sets/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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 - ) - ); - } + int64_t value = 0; + common::optional cause; - // 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. + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1942,32 +1926,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/sets/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/sets/expected_output/src/verification.cpp index 998e0cdc6..89ac39815 100644 --- a/dev/test_data/main/cpp/expected/sets/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/sets/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool TextsAreUnique( @@ -337,7 +347,8 @@ namespace { enum class Shape : std::uint32_t { kCode = 0, kSomething = 1, - kCollection = 2 + kCollection = 2, + kINT64 = 3 }; // enum class Shape /** @@ -634,6 +645,8 @@ bool Collection_5( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kCode: { static const std::vector checks = { @@ -747,6 +760,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -759,19 +776,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -930,6 +1008,67 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + /** * \brief Iterate over the values of the children, one child after another. * @@ -1347,10 +1486,28 @@ std::unique_ptr EachSorted( ); } +using listOf_int = std::vector; + using listOf_Code = std::vector; +using setOf_int = std::unordered_set; + using setOf_Code = std::unordered_set; +std::unique_ptr Over_int( + const int64_t& value, + bool +) { + return One(&value, Shape::kINT64); +} + +std::unique_ptr Over_listOf_int( + const listOf_int& value, + bool recursive +) { + return Each(value, &Over_int, recursive); +} + std::unique_ptr Over_Code( const std::wstring& value, bool @@ -1365,6 +1522,18 @@ std::unique_ptr Over_listOf_Code( return Each(value, &Over_Code, recursive); } +std::unique_ptr Over_setOf_int( + const setOf_int& value, + bool recursive +) { + return EachSorted( + value, + std::less(), + &Over_int, + recursive + ); +} + std::unique_ptr Over_setOf_Code( const setOf_Code& value, bool recursive @@ -1383,6 +1552,14 @@ std::unique_ptr Over_Something( ) { return Chain( One(&that, Shape::kSomething), + InProperty( + iteration::Property::kNumber, + OneByValue(that.number(), Shape::kINT64) + ), + InProperty( + iteration::Property::kNumbers, + Over_listOf_int(that.numbers(), recursive) + ), InProperty( iteration::Property::kCodes, Over_listOf_Code(that.codes(), recursive) @@ -1396,6 +1573,10 @@ std::unique_ptr Over_Collection( ) { return Chain( One(&that, Shape::kCollection), + InProperty( + iteration::Property::kNumbers, + Over_setOf_int(that.numbers(), recursive) + ), InProperty( iteration::Property::kCodes, Over_setOf_Code(that.codes(), recursive) diff --git a/dev/test_data/main/cpp/expected/sets/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/sets/expected_output/src/xmlization.cpp index 8e7c4d259..324c8400a 100644 --- a/dev/test_data/main/cpp/expected/sets/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/sets/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1087,6 +1088,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2635,6 +2644,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/sets/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/sets/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/sets/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/sets/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/sets/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/sets/expected_output/test/test_jsonization_of_concrete_classes.cpp index 4dfe3d234..a00ebaece 100644 --- a/dev/test_data/main/cpp/expected/sets/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/sets/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -325,16 +433,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Collection") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .number of Something" + "Test the safe integer range at .number of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -344,24 +450,45 @@ TEST_CASE( instance->set_number(value); - AssertSerializationFailsAt( - *instance, - ".number" + AssertReportedEverywhere( + instance, + ".number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_number(value); + + AssertNotReportedAnywhere(instance, ".number"); + } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .numbers[1] of Something" + "Test the safe integer range at .numbers[1] of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -376,10 +503,38 @@ TEST_CASE( } ); - AssertSerializationFailsAt( - *instance, - ".numbers[1]" + AssertReportedEverywhere( + instance, + ".numbers[1]", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_numbers( + std::vector{ + 0LL, + value + } ); + + AssertNotReportedAnywhere(instance, ".numbers[1]"); } } diff --git a/dev/test_data/main/cpp/expected/sets/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/sets/expected_output/test/test_verification.cpp index e8d2d0248..4d5866fab 100644 --- a/dev/test_data/main/cpp/expected/sets/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/sets/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -224,5 +225,54 @@ TEST_CASE("Test verification of invalid cases for Collection") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/string_operations/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/string_operations/expected_output/src/jsonization.cpp index 31bc2a82b..cd784f89b 100644 --- a/dev/test_data/main/cpp/expected/string_operations/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/string_operations/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1234,48 +1130,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/string_operations/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/string_operations/expected_output/src/verification.cpp index db55cb03b..42f2d428a 100644 --- a/dev/test_data/main/cpp/expected/string_operations/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/string_operations/expected_output/src/verification.cpp @@ -540,8 +540,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/string_operations/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/string_operations/expected_output/src/xmlization.cpp index ee3086856..ec5f2ef06 100644 --- a/dev/test_data/main/cpp/expected/string_operations/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/string_operations/expected_output/src/xmlization.cpp @@ -918,117 +918,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1633,18 +1522,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/string_operations/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/string_operations/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/string_operations/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/string_operations/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/switch_statements/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/switch_statements/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/switch_statements/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/switch_statements/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/switch_statements/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/switch_statements/expected_output/include/dummy/verification.hpp index 251de12d4..79d651395 100644 --- a/dev/test_data/main/cpp/expected/switch_statements/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/switch_statements/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/jsonization.cpp index 4a31b33e8..9e40b6d2f 100644 --- a/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1285,32 +1269,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/verification.cpp index c03623086..f43899f21 100644 --- a/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool SwitchOnEnumWithoutDefault( @@ -227,7 +237,8 @@ namespace { */ enum class Shape : std::uint32_t { kNonEmptyString = 0, - kSomething = 1 + kSomething = 1, + kINT64 = 2 }; // enum class Shape /** @@ -384,6 +395,8 @@ bool Something_10( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kNonEmptyString: { static const std::vector checks = { @@ -443,6 +456,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -455,19 +472,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -626,6 +704,67 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + /** * \brief Iterate over the values of the children, one child after another. * @@ -805,6 +944,10 @@ std::unique_ptr Over_Something( InProperty( iteration::Property::kName, One(&that.name(), Shape::kNonEmptyString) + ), + InProperty( + iteration::Property::kNumber, + OneByValue(that.number(), Shape::kINT64) ) ); } diff --git a/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/xmlization.cpp index 0119c4138..56b65af5c 100644 --- a/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/switch_statements/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1015,6 +1016,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -1670,6 +1679,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp index 745f907f0..677912bdf 100644 --- a/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected Something") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -269,16 +377,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .number of Something" + "Test the safe integer range at .number of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -288,11 +394,34 @@ TEST_CASE( instance->set_number(value); - AssertSerializationFailsAt( - *instance, - ".number" + AssertReportedEverywhere( + instance, + ".number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + instance->set_number(value); + + AssertNotReportedAnywhere(instance, ".number"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/test_verification.cpp index 75471666d..20920d7c8 100644 --- a/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/switch_statements/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -176,5 +177,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/include/dummy/common.hpp index 63442d045..baac79a4a 100644 --- a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/include/dummy/common.hpp @@ -10173,6 +10173,43 @@ std::wstring SliceStr( */ int64_t FloorMod(int64_t dividend, int64_t divisor); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/include/dummy/verification.hpp index ec2a772cd..141fb23ee 100644 --- a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/common.cpp index de3123ace..5cfb5b715 100644 --- a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/common.cpp @@ -14207,6 +14207,26 @@ int64_t FloorMod(int64_t dividend, int64_t divisor) { return remainder; } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/jsonization.cpp index 15e526ef2..e1df95fda 100644 --- a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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. + int64_t value = 0; + common::optional cause; + + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -1821,32 +1805,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/verification.cpp index 3a0089929..1966b35ac 100644 --- a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/verification.cpp @@ -39,6 +39,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool ResetAndCheck( @@ -72,7 +82,8 @@ namespace { */ enum class Shape : std::uint32_t { kFirst = 0, - kSecond = 1 + kSecond = 1, + kINT64 = 2 }; // enum class Shape /** @@ -187,6 +198,8 @@ bool Second_4( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kFirst: { static const std::vector checks = { @@ -234,6 +247,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -246,19 +263,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -417,18 +495,261 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + +/** + * \brief Iterate over the values of the children, one child after another. + * + * A child is started only once the previous child is done. + */ +class ChainIterator : public IIterator { + public: + explicit ChainIterator( + std::vector > children + ) : + children_(std::move(children)), + active_(0) { + // Intentionally empty. + } + + ChainIterator(const ChainIterator& other) : + active_(other.active_) { + children_.reserve(other.children_.size()); + for (const std::unique_ptr& child : other.children_) { + children_.emplace_back(child->Clone()); + } + } + + void Start() override { + active_ = 0; + if (!children_.empty()) { + children_[0]->Start(); + } + SkipDoneChildren(); + } + + void Next() override { + children_[active_]->Next(); + SkipDoneChildren(); + } + + bool Done() const override { + return active_ >= children_.size(); + } + + const void* Value() const override { + return children_[active_]->Value(); + } + + Shape ShapeOf() const override { + return children_[active_]->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + children_[active_]->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::vector > children_; + + /** + * Index of the child we currently iterate over + */ + std::size_t active_; + + /** + * Move on to the next children, and start them, until one is not done. + */ + void SkipDoneChildren() { + while (active_ < children_.size() && children_[active_]->Done()) { + ++active_; + if (active_ < children_.size()) { + children_[active_]->Start(); + } + } + } +}; // class ChainIterator + +void CollectChildren( + std::vector >& +) { + // Intentionally empty, as there are no more children to collect. +} + +template +void CollectChildren( + std::vector >& children, + std::unique_ptr first, + Rest... rest +) { + children.emplace_back(std::move(first)); + CollectChildren(children, std::move(rest)...); +} + +// NOTE (mristin): +// We can not use an initializer list here, as we can not move the unique pointers +// out of it. +template +std::unique_ptr Chain( + Children... children +) { + std::vector > collected; + collected.reserve(sizeof...(Children)); + CollectChildren(collected, std::move(children)...); + + return common::make_unique(std::move(collected)); +} + +/** + * Iterate over the values of the \p child, which lives in a property. + */ +class InPropertyIterator : public IIterator { + public: + InPropertyIterator( + iteration::Property property, + std::unique_ptr child + ) : + property_(property), + child_(std::move(child)) { + // Intentionally empty. + } + + InPropertyIterator(const InPropertyIterator& other) : + property_(other.property_), + child_(other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(property_) + ); + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + iteration::Property property_; + std::unique_ptr child_; +}; // class InPropertyIterator + +std::unique_ptr InProperty( + iteration::Property property, + std::unique_ptr child +) { + return common::make_unique(property, std::move(child)); +} + std::unique_ptr Over_First( const types::IFirst& that, bool ) { - return One(&that, Shape::kFirst); + return Chain( + One(&that, Shape::kFirst), + InProperty( + iteration::Property::kCount, + OneByValue(that.count(), Shape::kINT64) + ) + ); } std::unique_ptr Over_Second( const types::ISecond& that, bool ) { - return One(&that, Shape::kSecond); + return Chain( + One(&that, Shape::kSecond), + InProperty( + iteration::Property::kCount, + OneByValue(that.count(), Shape::kINT64) + ) + ); } /** diff --git a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/xmlization.cpp index 5d2d948a3..1a4d77ef7 100644 --- a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1142,6 +1143,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2194,6 +2203,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/test_jsonization_of_concrete_classes.cpp index 63faf0337..245922c6d 100644 --- a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected First") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -325,16 +433,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Second") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .count of First" + "Test the safe integer range at .count of First" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "First", @@ -344,24 +450,45 @@ TEST_CASE( instance->set_count(value); - AssertSerializationFailsAt( - *instance, - ".count" + AssertReportedEverywhere( + instance, + ".count", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "First", + our::jsonization::FirstFrom + ) + ); + + instance->set_count(value); + + AssertNotReportedAnywhere(instance, ".count"); + } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .count of Second" + "Test the safe integer range at .count of Second" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Second", @@ -371,11 +498,34 @@ TEST_CASE( instance->set_count(value); - AssertSerializationFailsAt( - *instance, - ".count" + AssertReportedEverywhere( + instance, + ".count", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Second", + our::jsonization::SecondFrom + ) + ); + + instance->set_count(value); + + AssertNotReportedAnywhere(instance, ".count"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/test_verification.cpp index 392e2b4d4..0c6fee20e 100644 --- a/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/transpiled_methods/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -224,5 +225,54 @@ TEST_CASE("Test verification of invalid cases for Second") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/tuples/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/tuples/expected_output/include/dummy/common.hpp index 6336e407c..fc3a01e72 100644 --- a/dev/test_data/main/cpp/expected/tuples/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/tuples/expected_output/include/dummy/common.hpp @@ -48,6 +48,10 @@ #pragma warning(pop) #endif +#pragma warning(push, 0) +#include +#pragma warning(pop) + namespace dummy { /** @@ -10100,6 +10104,43 @@ size_t LenTuple(const std::tuple&) { return sizeof...(T); } +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/tuples/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/tuples/expected_output/include/dummy/verification.hpp index 072afc8a4..94bce297f 100644 --- a/dev/test_data/main/cpp/expected/tuples/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/tuples/expected_output/include/dummy/verification.hpp @@ -133,6 +133,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; @@ -230,7 +242,8 @@ bool NarrowedPairIsPositive( // region Verification of constrained primitives /** - * \brief Verify that the invariants hold for \p that value. + * \brief Verify that the invariants hold for \p that value, and that + * it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. * * \param that value to be verified * \return Iterable over constraint violations diff --git a/dev/test_data/main/cpp/expected/tuples/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/tuples/expected_output/src/common.cpp index fbfe76815..f5900c9f3 100644 --- a/dev/test_data/main/cpp/expected/tuples/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/tuples/expected_output/src/common.cpp @@ -14060,6 +14060,26 @@ std::wstring Utf8ToWstring(const std::string& utf8_text) { return Utf8ToWstring(&(utf8_text[0]), utf8_text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/tuples/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/tuples/expected_output/src/jsonization.cpp index 1493958bf..24b5473f9 100644 --- a/dev/test_data/main/cpp/expected/tuples/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/tuples/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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()) - ); + int64_t value = 0; + common::optional cause; - 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. + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -2336,32 +2320,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/tuples/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/tuples/expected_output/src/verification.cpp index 6a98d1669..a850ad4e6 100644 --- a/dev/test_data/main/cpp/expected/tuples/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/tuples/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool SomeVerification( @@ -87,7 +97,8 @@ namespace { */ enum class Shape : std::uint32_t { kPositiveInt = 0, - kSomething = 1 + kSomething = 1, + kINT64 = 2 }; // enum class Shape /** @@ -148,6 +159,8 @@ bool Something_2( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kPositiveInt: { static const std::vector checks = { @@ -175,6 +188,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -187,19 +204,81 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kPositiveInt: + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -652,6 +731,104 @@ std::unique_ptr AtIndex( return common::make_unique(index, std::move(child)); } +/** + * \brief Iterate over the values of an instance, dispatched on its runtime type. + * + * Defined below, once all the classes have been covered. + */ +std::unique_ptr DispatchOnModelType( + const types::IClass& instance, + bool recursive +); + +/** + * \brief Iterate recursively over the values of an instance referenced from + * another instance. + * + * We dispatch on the runtime type of the instance only in \ref Start so that + * we descend into the instance only once the iteration reaches it. + */ +class DispatchingIterator : public IIterator { + public: + explicit DispatchingIterator( + const types::IClass* instance + ) : + instance_(instance) { + // Intentionally empty. + } + + DispatchingIterator(const DispatchingIterator& other) : + instance_(other.instance_), + child_(other.child_ == nullptr ? nullptr : other.child_->Clone()) { + // Intentionally empty. + } + + void Start() override { + child_ = DispatchOnModelType(*instance_, true); + child_->Start(); + } + + void Next() override { + child_->Next(); + } + + bool Done() const override { + return child_->Done(); + } + + const void* Value() const override { + return child_->Value(); + } + + Shape ShapeOf() const override { + return child_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + child_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + const types::IClass* instance_; + std::unique_ptr child_; +}; // class DispatchingIterator + +std::unique_ptr Over( + const types::IClass& instance, + bool recursive +) { + if (!recursive) { + // NOTE (mristin): + // In the non-recursive mode, we verify only the instance itself, but not + // the instances that it references. + return Empty(); + } + + return common::make_unique(&instance); +} + +template +std::unique_ptr ThroughPointer( + const std::shared_ptr& instance, + bool recursive +) { + return Over(*instance, recursive); +} + +using tupleOf2_str_int = std::tuple< + std::wstring, + int64_t +>; + +using tupleOf2_AbstractItem_AbstractItem = std::tuple< + std::shared_ptr, + std::shared_ptr +>; + using tupleOf6_int_SomeItem_AbstractItem_SomeItem_PositiveInt_Result = std::tuple< int64_t, std::shared_ptr, @@ -661,11 +838,62 @@ using tupleOf6_int_SomeItem_AbstractItem_SomeItem_PositiveInt_Result = std::tupl types::Result >; +using tupleOf2_str_AbstractItem = std::tuple< + std::wstring, + std::shared_ptr +>; + +std::unique_ptr Over_tupleOf2_str_int( + const tupleOf2_str_int& value, + bool +) { + return AtIndex(1, One(&std::get<1>(value), Shape::kINT64)); +} + +std::unique_ptr Over_tupleOf2_AbstractItem_AbstractItem( + const tupleOf2_AbstractItem_AbstractItem& value, + bool recursive +) { + if (!recursive) { + return Empty(); + } + + return Chain( + AtIndex(0, Over(*std::get<0>(value), recursive)), + AtIndex(1, Over(*std::get<1>(value), recursive)) + ); +} + std::unique_ptr Over_tupleOf6_int_SomeItem_AbstractItem_SomeItem_PositiveInt_Result( const tupleOf6_int_SomeItem_AbstractItem_SomeItem_PositiveInt_Result& value, + bool recursive +) { + return Chain( + AtIndex(0, One(&std::get<0>(value), Shape::kINT64)), + AtIndex(2, Over(*std::get<2>(value), recursive)), + AtIndex(4, One(&std::get<4>(value), Shape::kPositiveInt)) + ); +} + +std::unique_ptr Over_tupleOf2_str_AbstractItem( + const tupleOf2_str_AbstractItem& value, + bool recursive +) { + if (!recursive) { + return Empty(); + } + + return AtIndex(1, Over(*std::get<1>(value), recursive)); +} + +std::unique_ptr Over_AnotherItem( + const types::IAnotherItem& that, bool ) { - return AtIndex(4, One(&std::get<4>(value), Shape::kPositiveInt)); + return InProperty( + iteration::Property::kSerialNumber, + OneByValue(that.serial_number(), Shape::kINT64) + ); } std::unique_ptr Over_Something( @@ -674,12 +902,32 @@ std::unique_ptr Over_Something( ) { return Chain( One(&that, Shape::kSomething), + InProperty( + iteration::Property::kPair, + Over_tupleOf2_str_int(that.pair(), recursive) + ), + InProperty( + iteration::Property::kItems, + Over_tupleOf2_AbstractItem_AbstractItem( + that.items(), + recursive + ) + ), InProperty( iteration::Property::kTricky, Over_tupleOf6_int_SomeItem_AbstractItem_SomeItem_PositiveInt_Result( that.tricky(), recursive ) + ), + InProperty( + iteration::Property::kOptionalPair, + that.optional_pair().has_value() + ? Over_tupleOf2_str_AbstractItem( + (*that.optional_pair()), + recursive + ) + : Empty() ) ); } @@ -692,6 +940,11 @@ std::unique_ptr DispatchOnModelType( bool recursive ) { switch (instance.model_type()) { + case types::ModelType::kAnotherItem: + return Over_AnotherItem( + dynamic_cast(instance), + recursive + ); case types::ModelType::kSomething: return Over_Something( dynamic_cast(instance), diff --git a/dev/test_data/main/cpp/expected/tuples/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/tuples/expected_output/src/xmlization.cpp index f47392910..8c408e689 100644 --- a/dev/test_data/main/cpp/expected/tuples/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/tuples/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1190,6 +1191,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2752,6 +2761,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/tuples/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/tuples/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/tuples/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/tuples/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/tuples/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/tuples/expected_output/test/test_jsonization_of_concrete_classes.cpp index 249369e3e..de2628f58 100644 --- a/dev/test_data/main/cpp/expected/tuples/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/tuples/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected SomeItem") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -381,16 +489,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Something") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .serial_number of AnotherItem" + "Test the safe integer range at .serial_number of AnotherItem" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "AnotherItem", @@ -400,24 +506,45 @@ TEST_CASE( instance->set_serial_number(value); - AssertSerializationFailsAt( - *instance, - ".serial_number" + AssertReportedEverywhere( + instance, + ".serial_number", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "AnotherItem", + our::jsonization::AnotherItemFrom + ) ); + + instance->set_serial_number(value); + + AssertNotReportedAnywhere(instance, ".serial_number"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .pair[1] of Something" + "Test the safe integer range at .pair[1] of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -429,24 +556,47 @@ TEST_CASE( instance->mutable_pair() ) = value; - AssertSerializationFailsAt( - *instance, - ".pair[1]" + AssertReportedEverywhere( + instance, + ".pair[1]", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ); + } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) ); + + std::get<1>( + instance->mutable_pair() + ) = value; + + AssertNotReportedAnywhere(instance, ".pair[1]"); } } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .tricky[0] of Something" + "Test the safe integer range at .tricky[0] of Something" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "Something", @@ -458,11 +608,36 @@ TEST_CASE( instance->mutable_tricky() ) = value; - AssertSerializationFailsAt( - *instance, - ".tricky[0]" + AssertReportedEverywhere( + instance, + ".tricky[0]", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "Something", + our::jsonization::SomethingFrom + ) + ); + + std::get<0>( + instance->mutable_tricky() + ) = value; + + AssertNotReportedAnywhere(instance, ".tricky[0]"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/tuples/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/tuples/expected_output/test/test_verification.cpp index eaed1c972..70edbd94a 100644 --- a/dev/test_data/main/cpp/expected/tuples/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/tuples/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -272,5 +273,54 @@ TEST_CASE("Test verification of invalid cases for Something") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/unions/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/unions/expected_output/src/jsonization.cpp index b8e7584d2..93dfe78ef 100644 --- a/dev/test_data/main/cpp/expected/unions/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/unions/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -4161,48 +4057,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/unions/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/unions/expected_output/src/verification.cpp index 22a8a0545..19eb0e86d 100644 --- a/dev/test_data/main/cpp/expected/unions/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/unions/expected_output/src/verification.cpp @@ -277,8 +277,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/unions/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/unions/expected_output/src/xmlization.cpp index 6e4c9706c..4a6657b22 100644 --- a/dev/test_data/main/cpp/expected/unions/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/unions/expected_output/src/xmlization.cpp @@ -1796,117 +1796,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -4057,18 +3946,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/unions/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/unions/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/unions/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/unions/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/include/dummy/common.hpp b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/include/dummy/common.hpp index d9dea22f6..1436d2228 100644 --- a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/include/dummy/common.hpp +++ b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/include/dummy/common.hpp @@ -10118,6 +10118,43 @@ size_t LenTuple(const std::tuple&) { */ size_t LenStr(const std::wstring& text); +/** + * \brief Check that the integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * JSON knows a single numeric type, which many readers parse into a 64-bit + * floating-point number. Outside the safe integer range, such a reader might + * quietly get a different integer than the one which was written. + * + * The verification, the JSON and XML de/serialization and ParseSafeInt all + * use this check, so that an instance which passes the verification can be + * serialized to JSON. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(int64_t value); + +/** + * \brief Check that the unsigned integer \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * \param value to be checked + * \return true if \p value lies in the range + */ +bool IsSafeInteger(uint64_t value); + +/** + * \brief Check that the whole number \p value lies in the safe integer range + * [-2^53 + 1, 2^53 - 1]. + * + * We check only the range. The caller has to check that \p value is whole. + * + * \param value to be checked + * \return true if \p value lies in the range, false for a not-a-number + */ +bool IsSafeInteger(double value); + } // namespace common /**@}*/ diff --git a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/include/dummy/verification.hpp b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/include/dummy/verification.hpp index 97dd9b21f..3ef3749f5 100644 --- a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/include/dummy/verification.hpp +++ b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/include/dummy/verification.hpp @@ -139,6 +139,18 @@ class IVerificator { } // namespace impl /// \endcond +/// \cond HIDDEN +namespace impl { +/** + * \brief Describe that the \p integer lies outside the safe integer range. + * + * \param integer textual representation of the integer + * \return the cause of the error + */ +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer); +} // namespace impl +/// \endcond + class IVerification { public: virtual Iterator begin() const = 0; diff --git a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/common.cpp b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/common.cpp index a03374953..2f8acc334 100644 --- a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/common.cpp +++ b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/common.cpp @@ -14119,6 +14119,26 @@ size_t LenStr(const std::wstring& text) { return CountCharacters(text, 0, text.size()); } +bool IsSafeInteger(int64_t value) { + return ( + -9007199254740991LL <= value + && value <= 9007199254740991LL + ); +} + +bool IsSafeInteger(uint64_t value) { + return value <= 9007199254740991ULL; +} + +bool IsSafeInteger(double value) { + // NOTE (mristin): + // A not-a-number fails both comparisons, so it lies outside the range. + return ( + -9007199254740991.0 <= value + && value <= 9007199254740991.0 + ); +} + } // namespace common } // namespace dummy diff --git a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/jsonization.cpp index 6fec18c96..0dea12315 100644 --- a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/jsonization.cpp @@ -3,6 +3,7 @@ #include "dummy/jsonization.hpp" #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #pragma warning(push, 0) @@ -308,102 +309,85 @@ std::pair< > 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()) - ); + int64_t value = 0; + common::optional cause; - 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. + switch (json.type()) { + case nlohmann::json::value_t::number_integer: { + const int64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = integer; + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - const nlohmann::json::number_float_t number( - json.get() - ); + case nlohmann::json::value_t::number_unsigned: { + const uint64_t integer = json.get(); + if (common::IsSafeInteger(integer)) { + value = static_cast(integer); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(integer) + ); + } + break; + } - 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) - ); + case nlohmann::json::value_t::number_float: { + const double number = json.get(); + if (!std::isfinite(number) || std::trunc(number) != number) { + cause = common::Concat( + L"Expected an integer, but got ", + std::to_wstring(number), + L", which is not a whole number" + ); + } else if (common::IsSafeInteger(number)) { + value = static_cast(number); + } else { + cause = verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(number) + ); + } + break; + } - 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) - ); + default: + cause = common::Concat( + L"Expected an integer, but got: ", + common::Utf8ToWstring(json.type_name()) + ); + break; + } + if (cause.has_value()) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + std::move(*cause) ) ); } + + return std::make_pair< + common::optional, + common::optional + >( + value, + common::nullopt + ); } std::pair< @@ -2124,32 +2108,24 @@ nlohmann::json SerializeBool( /** * \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. + * We verify that the integer is within the safe integer range, which 64-bit + * floating-point numbers represent exactly, 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." - ); - + if (!common::IsSafeInteger(value)) { return std::make_pair< common::optional, common::optional >( common::nullopt, common::make_optional( - message + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) ) ); } diff --git a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/verification.cpp index ea55dcf18..8241bbe04 100644 --- a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/verification.cpp @@ -37,6 +37,16 @@ Error::Error( // endregion struct Error +namespace impl { +std::wstring DescribeIntegerOutsideSafeRange(const std::wstring& integer) { + return common::Concat( + L"Expected an integer, but got ", + integer, + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} +} // namespace impl + // region Verification functions bool ContainsSpecialItem( @@ -101,7 +111,8 @@ namespace { */ enum class Shape : std::uint32_t { kContainer = 0, - kWrapper = 1 + kWrapper = 1, + kINT64 = 2 }; // enum class Shape /** @@ -343,6 +354,8 @@ bool Wrapper_0( * Give out the checks of the values of the \p shape. */ const std::vector& ChecksOf(Shape shape) { + static const std::vector kNoChecks; + switch (shape) { case Shape::kContainer: { static const std::vector checks = { @@ -426,6 +439,10 @@ const std::vector& ChecksOf(Shape shape) { }; return checks; } + case Shape::kINT64: + // NOTE (mristin): + // These values are verified by the nested verification. + return kNoChecks; default: throw std::logic_error( common::Concat( @@ -438,19 +455,80 @@ const std::vector& ChecksOf(Shape shape) { // endregion Checks +/** + * \brief Report a single error whose cause depends on the value. + * + * The path of the error is empty, as \ref ErrorIterator prefixes it with + * the path to the value. + */ +class SingleErrorVerificator : public impl::IVerificator { + public: + explicit SingleErrorVerificator( + std::wstring cause + ) : + cause_(std::move(cause)) { + // Intentionally empty. + } + + void Start() override { + // NOTE (mristin): + // We copy the cause as the caller might take over the data members of + // the error, and we still need to be able to start again. + error_ = Error(cause_); + } + + void Next() override { + error_ = common::nullopt; + } + + bool Done() const override { + return !error_.has_value(); + } + + const Error& Get() const override { + return *error_; + } + + Error& GetMutable() override { + return *error_; + } + + long Index() const override { + return error_.has_value() ? 0 : -1; + } + + std::unique_ptr Clone() const override { + return common::make_unique(*this); + } + + private: + std::wstring cause_; + common::optional error_; +}; // class SingleErrorVerificator + /** * \brief Create the nested verification of the \p value, if its \p shape has one. * * \return nullptr if the shape has no nested verification */ std::unique_ptr NewNestedVerificator( - Shape, - const void* + Shape shape, + const void* value ) { - // NOTE (mristin): - // The meta-model uses no JSON-able values, so no value needs a nested - // verification. - return nullptr; + switch (shape) { + case Shape::kINT64: { + const int64_t integer = *static_cast(value); + if (common::IsSafeInteger(integer)) { + return nullptr; + } + + return common::make_unique( + impl::DescribeIntegerOutsideSafeRange(std::to_wstring(integer)) + ); + } + default: + return nullptr; + } } // region Iteration over the values @@ -609,6 +687,67 @@ std::unique_ptr One( return common::make_unique(value, shape); } +/** + * \brief Iterate over a single value which we keep a copy of. + * + * The getters of booleans, integers and floating-point numbers return by value, + * so these values have no address in the model which we could point to. + */ +template +class OneByValueIterator : public IIterator { + public: + OneByValueIterator( + T value, + Shape shape + ) : + value_(value), + shape_(shape), + done_(true) { + // Intentionally empty. + } + + void Start() override { + done_ = false; + } + + void Next() override { + done_ = true; + } + + bool Done() const override { + return done_; + } + + const void* Value() const override { + return &value_; + } + + Shape ShapeOf() const override { + return shape_; + } + + void AppendToPath(iteration::Path&) const override { + // Intentionally empty, as the value itself is the end of the path. + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + T value_; + Shape shape_; + bool done_; +}; // class OneByValueIterator + +template +std::unique_ptr OneByValue( + T value, + Shape shape +) { + return common::make_unique >(value, shape); +} + /** * \brief Iterate over the values of the children, one child after another. * @@ -779,6 +918,118 @@ std::unique_ptr InProperty( return common::make_unique(property, std::move(child)); } +/** + * \brief Iterate over the values of every item of a list, one item after another. + * + * The iterator over an item is built only once the iteration reaches the item. + */ +template +class EachIterator : public IIterator { + public: + /** + * Build the iterator over the values of an item + */ + typedef std::unique_ptr (*OverItem)(const T& item, bool recursive); + + EachIterator( + const std::vector* items, + OverItem over_item, + bool recursive + ) : + items_(items), + over_item_(over_item), + recursive_(recursive), + index_(0) { + // Intentionally empty. + } + + EachIterator(const EachIterator& other) : + items_(other.items_), + over_item_(other.over_item_), + recursive_(other.recursive_), + index_(other.index_), + item_(other.item_ == nullptr ? nullptr : other.item_->Clone()) { + // Intentionally empty. + } + + void Start() override { + index_ = 0; + item_ = nullptr; + SkipDoneItems(); + } + + void Next() override { + item_->Next(); + SkipDoneItems(); + } + + bool Done() const override { + return index_ >= items_->size(); + } + + const void* Value() const override { + return item_->Value(); + } + + Shape ShapeOf() const override { + return item_->ShapeOf(); + } + + void AppendToPath(iteration::Path& path) const override { + path.segments.emplace_back( + common::make_unique(index_) + ); + item_->AppendToPath(path); + } + + std::unique_ptr Clone() const override { + return common::make_unique >(*this); + } + + private: + const std::vector* items_; + OverItem over_item_; + bool recursive_; + + /** + * Index of the item we currently iterate over + */ + std::size_t index_; + + /** + * Iterator over the current item, built once we reached the item + */ + std::unique_ptr item_; + + /** + * Move on to the next items, and build their iterators, until one is not done. + */ + void SkipDoneItems() { + while (index_ < items_->size()) { + if (item_ == nullptr) { + item_ = over_item_((*items_)[index_], recursive_); + item_->Start(); + } + + if (!item_->Done()) { + return; + } + + item_ = nullptr; + ++index_; + } + } +}; // class EachIterator + +template +std::unique_ptr Each( + const std::vector& items, + std::unique_ptr (*over_item)(const T& item, bool recursive), + bool recursive +) { + return common::make_unique >(&items, over_item, recursive); +} + /** * \brief Iterate over the values of an instance, dispatched on its runtime type. * @@ -867,11 +1118,82 @@ std::unique_ptr ThroughPointer( return Over(*instance, recursive); } +using listOf_Item = std::vector< + std::shared_ptr +>; + +using listOf_SomeItem = std::vector< + std::shared_ptr +>; + +using listOf_SpecialItem = std::vector< + std::shared_ptr +>; + +std::unique_ptr Over_listOf_Item( + const listOf_Item& value, + bool recursive +) { + if (!recursive) { + return Empty(); + } + + return Each(value, &ThroughPointer, recursive); +} + +std::unique_ptr Over_listOf_SomeItem( + const listOf_SomeItem& value, + bool recursive +) { + if (!recursive) { + return Empty(); + } + + return Each(value, &ThroughPointer, recursive); +} + +std::unique_ptr Over_listOf_SpecialItem( + const listOf_SpecialItem& value, + bool recursive +) { + if (!recursive) { + return Empty(); + } + + return Each(value, &ThroughPointer, recursive); +} + +std::unique_ptr Over_SpecialItem( + const types::ISpecialItem& that, + bool +) { + return InProperty( + iteration::Property::kLevel, + OneByValue(that.level(), Shape::kINT64) + ); +} + std::unique_ptr Over_Container( const types::IContainer& that, - bool + bool recursive ) { - return One(&that, Shape::kContainer); + return Chain( + One(&that, Shape::kContainer), + InProperty( + iteration::Property::kItems, + Over_listOf_Item(that.items(), recursive) + ), + InProperty( + iteration::Property::kSomeItems, + Over_listOf_SomeItem(that.some_items(), recursive) + ), + InProperty( + iteration::Property::kSpecialItems, + that.special_items().has_value() + ? Over_listOf_SpecialItem((*that.special_items()), recursive) + : Empty() + ) + ); } std::unique_ptr Over_Wrapper( @@ -895,6 +1217,11 @@ std::unique_ptr DispatchOnModelType( bool recursive ) { switch (instance.model_type()) { + case types::ModelType::kSpecialItem: + return Over_SpecialItem( + dynamic_cast(instance), + recursive + ); case types::ModelType::kContainer: return Over_Container( dynamic_cast(instance), diff --git a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/xmlization.cpp index 92ee9b02e..b0845e1f8 100644 --- a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/src/xmlization.cpp @@ -2,6 +2,7 @@ // Do NOT edit or append. #include "dummy/stringification.hpp" +#include "dummy/verification.hpp" #include "dummy/wstringification.hpp" #include "dummy/xmlization.hpp" @@ -1266,6 +1267,14 @@ std::pair< ); } + if (!common::IsSafeInteger(*deserialized)) { + return NoInstanceAndDeserializationErrorWithCause( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(*deserialized) + ) + ); + } + // NOTE (mristin): // We consume the text node. reader.Read(); @@ -2374,6 +2383,14 @@ common::optional WriteInt64( int64_t value, xml_common::SelfClosingWriter& writer ) { + if (!common::IsSafeInteger(value)) { + return xml_common::SerializationError( + verification::impl::DescribeIntegerOutsideSafeRange( + std::to_wstring(value) + ) + ); + } + writer.SerializeInt64(value); if (writer.error().has_value()) { return writer.move_error(); diff --git a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/test_jsonization_of_concrete_classes.cpp b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/test_jsonization_of_concrete_classes.cpp index 987c8d300..cd5317cb4 100644 --- a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/test_jsonization_of_concrete_classes.cpp +++ b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/test_jsonization_of_concrete_classes.cpp @@ -5,8 +5,10 @@ #include "./common_jsonization.hpp" #include +#include +#include -#include +#include #include @@ -212,6 +214,112 @@ void AssertSerializationFailsAt( REQUIRE(false); } +/** + * Assert that the verification, the JSON serialization and the XML serialization + * of \p that instance all report \p expected_cause at \p expected_path. + */ +void AssertReportedEverywhere( + const std::shared_ptr& that, + const std::string& expected_path, + const std::wstring& expected_cause +) { + bool reported = false; + std::vector error_messages; + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + const std::string path( + our::common::WstringToUtf8(error.path.ToWstring()) + ); + if (path == expected_path && error.cause == expected_cause) { + reported = true; + break; + } + + error_messages.emplace_back( + our::common::Concat( + path, + ": ", + our::common::WstringToUtf8(error.cause) + ) + ); + } + + INFO( + our::common::Concat( + "Expected the verification to report at ", + expected_path, + ": ", + our::common::WstringToUtf8(expected_cause), + ", but got:\n", + test::common::JoinStrings(error_messages, "\n") + ) + ) + REQUIRE(reported); + + try { + our::jsonization::Serialize(*that); + + INFO("Expected the JSON serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::jsonization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } + + std::ostringstream oss; + try { + our::xmlization::Serialize(*that, {}, oss); + + INFO("Expected the XML serialization to fail at " + expected_path) + REQUIRE(false); + } catch (const our::xmlization::SerializationException& exception) { + REQUIRE( + our::common::WstringToUtf8(exception.path().ToWstring()) + == expected_path + ); + REQUIRE(exception.cause() == expected_cause); + } +} + +/** + * Assert that neither the verification nor the JSON serialization nor the XML + * serialization of \p that instance report an error of the safe integer range + * at \p path. + */ +void AssertNotReportedAnywhere( + const std::shared_ptr& that, + const std::string& path +) { + for ( + const our::verification::Error& error + : our::verification::RecursiveVerification(that) + ) { + INFO( + our::common::Concat( + our::common::WstringToUtf8(error.path.ToWstring()), + ": ", + our::common::WstringToUtf8(error.cause) + ) + ) + REQUIRE( + !( + our::common::WstringToUtf8(error.path.ToWstring()) == path + && error.cause.find(L"safe integer range") != std::wstring::npos + ) + ); + } + + REQUIRE_NOTHROW(our::jsonization::Serialize(*that)); + + std::ostringstream oss; + REQUIRE_NOTHROW(our::xmlization::Serialize(*that, {}, oss)); +} + TEST_CASE("Test the round-trip of an expected SomeItem") { const std::deque paths( test::common::FindFilesBySuffixRecursively( @@ -437,16 +545,14 @@ TEST_CASE("Test the de-serialization failure on an unexpected Wrapper") { } TEST_CASE( - "Test the serialization failure on an integer outside the range representable in JSON " - "at .level of SpecialItem" + "Test the safe integer range at .level of SpecialItem" ) { for ( - const int64_t value - : { - 9007199254740992LL, - -9007199254740992LL - } + const int64_t integer + : {9007199254740992LL, -9007199254740992LL} ) { + const int64_t value(integer); + std::shared_ptr instance( MustDeserializeTheFirstExpected( "SpecialItem", @@ -456,11 +562,34 @@ TEST_CASE( instance->set_level(value); - AssertSerializationFailsAt( - *instance, - ".level" + AssertReportedEverywhere( + instance, + ".level", + our::common::Concat( + L"Expected an integer, but got ", + std::to_wstring(integer), + L", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ); } + + for ( + const int64_t integer + : {9007199254740991LL, -9007199254740991LL} + ) { + const int64_t value(integer); + + std::shared_ptr instance( + MustDeserializeTheFirstExpected( + "SpecialItem", + our::jsonization::SpecialItemFrom + ) + ); + + instance->set_level(value); + + AssertNotReportedAnywhere(instance, ".level"); + } } // This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/test_verification.cpp b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/test_verification.cpp index bd9d7d7fb..f574153e1 100644 --- a/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/test_verification.cpp +++ b/dev/test_data/main/cpp/expected/variance_of_containers/expected_output/test/test_verification.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace our = dummy; @@ -320,5 +321,54 @@ TEST_CASE("Test verification of invalid cases for Wrapper") { } } +TEST_CASE("Test the predicates of the safe integer range") { + for ( + const int64_t value + : {9007199254740991LL, -9007199254740991LL, 0LL} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const int64_t value + : { + int64_t(9007199254740992LL), + int64_t(-9007199254740992LL), + std::numeric_limits::max(), + std::numeric_limits::min() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } + + REQUIRE(our::common::IsSafeInteger(uint64_t(9007199254740991ULL))); + REQUIRE(!our::common::IsSafeInteger(uint64_t(9007199254740992ULL))); + REQUIRE( + !our::common::IsSafeInteger( + std::numeric_limits::max() + ) + ); + + for ( + const double value + : {9007199254740991.0, -9007199254740991.0, 0.0} + ) { + REQUIRE(our::common::IsSafeInteger(value)); + } + + for ( + const double value + : { + 9007199254740992.0, + -9007199254740992.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() + } + ) { + REQUIRE(!our::common::IsSafeInteger(value)); + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/cpp/expected/visibility/expected_output/src/jsonization.cpp b/dev/test_data/main/cpp/expected/visibility/expected_output/src/jsonization.cpp index 074dd98f4..ce6fd4c36 100644 --- a/dev/test_data/main/cpp/expected/visibility/expected_output/src/jsonization.cpp +++ b/dev/test_data/main/cpp/expected/visibility/expected_output/src/jsonization.cpp @@ -302,110 +302,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 @@ -1364,48 +1260,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/visibility/expected_output/src/verification.cpp b/dev/test_data/main/cpp/expected/visibility/expected_output/src/verification.cpp index 2b85236be..f993ea0cd 100644 --- a/dev/test_data/main/cpp/expected/visibility/expected_output/src/verification.cpp +++ b/dev/test_data/main/cpp/expected/visibility/expected_output/src/verification.cpp @@ -214,8 +214,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/visibility/expected_output/src/xmlization.cpp b/dev/test_data/main/cpp/expected/visibility/expected_output/src/xmlization.cpp index 9da3612b6..63b59b42f 100644 --- a/dev/test_data/main/cpp/expected/visibility/expected_output/src/xmlization.cpp +++ b/dev/test_data/main/cpp/expected/visibility/expected_output/src/xmlization.cpp @@ -945,117 +945,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( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } - - common::optional deserialized; - - static_assert( - sizeof(int) == 8 - || sizeof(long) == 8 - || sizeof(long long) == 8, - "Neither int nor long nor long long are 8 bytes long, " - "so we do not know how to parse an xs:long." - ); - - try { - // NOTE (mristin): - // We remove the warning C4101 in MSVC with constants. - // See: https://stackoverflow.com/questions/25573996/c4127-conditional-expression-is-constant - const bool sizeof_int_is_8 = sizeof(int) == 8; - const bool sizeof_long_is_8 = sizeof(long) == 8; - const bool sizeof_long_long_is_8 = sizeof(long long) == 8; - - if (sizeof_int_is_8) { - deserialized = std::stoi(text); - } else if (sizeof_long_is_8) { - deserialized = std::stol(text); - } else if (sizeof_long_long_is_8) { - deserialized = std::stoll(text); - } - } catch (std::invalid_argument&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got an invalid value: ", - common::Utf8ToWstring(text) - ) - ); - } catch (std::out_of_range&) { - return NoInstanceAndDeserializationErrorWithCause( - common::Concat( - L"Expected to parse an xs:long from text, " - L"but got a value out of the xs:long range: ", - common::Utf8ToWstring(text) - ) - ); - } - - if (!deserialized.has_value()) { - throw std::logic_error( - "Neither int nor long nor long long are 8 bytes long, " - "but this should have been caught earlier in the static assert" - ); - } - - // NOTE (mristin): - // We consume the text node. - reader.Read(); - - if (reader.node().kind() == xml_common::NodeKind::Error) { - return NoInstanceAndDeserializationErrorFromReader(reader); - } - - return std::make_pair( - deserialized, - common::nullopt - ); -} - std::pair< common::optional, common::optional @@ -1613,18 +1502,6 @@ common::optional WriteBool( return common::nullopt; } -common::optional WriteInt64( - int64_t value, - xml_common::SelfClosingWriter& writer -) { - writer.SerializeInt64(value); - if (writer.error().has_value()) { - return writer.move_error(); - } - - return common::nullopt; -} - common::optional WriteDouble( double value, xml_common::SelfClosingWriter& writer diff --git a/dev/test_data/main/cpp/expected/visibility/expected_output/test/common.hpp b/dev/test_data/main/cpp/expected/visibility/expected_output/test/common.hpp index fa5b4f8e5..8c16d046a 100644 --- a/dev/test_data/main/cpp/expected/visibility/expected_output/test/common.hpp +++ b/dev/test_data/main/cpp/expected/visibility/expected_output/test/common.hpp @@ -77,6 +77,13 @@ template std::string JoinStrings(const T& container, const std::string& delimiter) { static_assert(std::is_same::value); + // NOTE (mristin): + // We have to handle the empty container explicitly, as the computations + // below subtract one from its size, which would wrap around. + if (container.empty()) { + return ""; + } + size_t size = 0; for (const std::string& part : container ) { diff --git a/dev/test_data/main/csharp/expected/aas_core_meta.v3/expected_output/AasCore.Aas3_0/jsonization.cs b/dev/test_data/main/csharp/expected/aas_core_meta.v3/expected_output/AasCore.Aas3_0/jsonization.cs index c352c3d84..f5013bc80 100644 --- a/dev/test_data/main/csharp/expected/aas_core_meta.v3/expected_output/AasCore.Aas3_0/jsonization.cs +++ b/dev/test_data/main/csharp/expected/aas_core_meta.v3/expected_output/AasCore.Aas3_0/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - ///

- /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -7219,28 +7189,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..ec029cebc --- /dev/null +++ b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,324 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestChildAAOnlyOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.ChildAFrom( + LoadTheFirstExpected("ChildA")); + + instance.AOnly = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "aOnly"); + } + } // public void TestChildAAOnlyOutsideTheSafeRange + + [Test] + public void TestChildAAOnlyAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.ChildAFrom( + LoadTheFirstExpected("ChildA")); + + instance.AOnly = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.ChildAFrom, + Our.Xmlization.Deserialize.ChildAFrom); + } + } // public void TestChildAAOnlyAtTheBoundsOfTheSafeRange + + [Test] + public void TestChildBBOnlyOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.ChildBFrom( + LoadTheFirstExpected("ChildB")); + + instance.BOnly = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "bOnly"); + } + } // public void TestChildBBOnlyOutsideTheSafeRange + + [Test] + public void TestChildBBOnlyAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.ChildBFrom( + LoadTheFirstExpected("ChildB")); + + instance.BOnly = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.ChildBFrom, + Our.Xmlization.Deserialize.ChildBFrom); + } + } // public void TestChildBBOnlyAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingNumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "number"); + } + } // public void TestSomethingNumberOutsideTheSafeRange + + [Test] + public void TestSomethingNumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingNumberAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/jsonization.cs index 2563f6a9b..c474457fd 100644 --- a/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -1311,48 +1362,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/verification.cs index b7bef033d..08ce99e1c 100644 --- a/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/verification.cs @@ -479,8 +479,12 @@ Our.IItem that Our.IChildA that ) { - // No verification has been defined for ChildA. - yield break; + foreach (var error in Verification.Verify_long(that.AOnly)) + { + error.PrependSegment( + new Reporting.NameSegment("aOnly")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -488,8 +492,12 @@ Our.IChildA that Our.IChildB that ) { - // No verification has been defined for ChildB. - yield break; + foreach (var error in Verification.Verify_long(that.BOnly)) + { + error.PrependSegment( + new Reporting.NameSegment("bOnly")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -656,6 +664,13 @@ Our.ISomething that "The first parent as Child_a must have a small a_only"); } + foreach (var error in Verification.Verify_long(that.Number)) + { + error.PrependSegment( + new Reporting.NameSegment("number")); + yield return error; + } + foreach (var error in Verification.VerifyKind(that.Kind)) { error.PrependSegment( @@ -777,6 +792,36 @@ var error in Verification.VerifyKind( } } + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + /// /// Verify the items of recursively. /// diff --git a/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/xmlization.cs index bb973ac49..467d2a68f 100644 --- a/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/annotated_assignments/expected_output/dummy/xmlization.cs @@ -246,6 +246,41 @@ private static byte[] ReadContentAsBytes(Xml.XmlReader reader) reader); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -561,7 +596,8 @@ out Reporting.Error? error AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_bool = ( AsText(ReadContentAsBoolean)); @@ -1976,7 +2012,16 @@ private static void WriteIUnion( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_bool = ( (that, writer) => writer.WriteValue(that)); diff --git a/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..0363ee185 --- /dev/null +++ b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,476 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSomethingEvenOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Even = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "even"); + } + } // public void TestSomethingEvenOutsideTheSafeRange + + [Test] + public void TestSomethingEvenAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Even = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingEvenAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingOffsetOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Offset = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "offset"); + } + } // public void TestSomethingOffsetOutsideTheSafeRange + + [Test] + public void TestSomethingOffsetAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Offset = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingOffsetAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingSmallOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Small = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "small"); + } + } // public void TestSomethingSmallOutsideTheSafeRange + + [Test] + public void TestSomethingSmallAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Small = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingSmallAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingByNegativeOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.ByNegative = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "byNegative"); + } + } // public void TestSomethingByNegativeOutsideTheSafeRange + + [Test] + public void TestSomethingByNegativeAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.ByNegative = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingByNegativeAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingNegativeOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Negative = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "negative"); + } + } // public void TestSomethingNegativeOutsideTheSafeRange + + [Test] + public void TestSomethingNegativeAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Negative = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingNegativeAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingDeviationOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Deviation = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "deviation"); + } + } // public void TestSomethingDeviationOutsideTheSafeRange + + [Test] + public void TestSomethingDeviationAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Deviation = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingDeviationAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingAlignmentOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Alignment = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "alignment"); + } + } // public void TestSomethingAlignmentOutsideTheSafeRange + + [Test] + public void TestSomethingAlignmentAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Alignment = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingAlignmentAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/common.cs index eaf1c306b..ca91a0ae6 100644 --- a/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/common.cs @@ -123,6 +123,58 @@ public static long FloorMod(long dividend, long divisor) return remainder; } + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/jsonization.cs index 54fbb3faa..f4f56d40f 100644 --- a/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } + + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -549,48 +600,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/verification.cs index abdebf942..6a8f56f97 100644 --- a/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/verification.cs @@ -225,6 +225,60 @@ Our.ISomething that new Reporting.NameSegment("even")); yield return error; } + + foreach (var error in Verification.Verify_long(that.Offset)) + { + error.PrependSegment( + new Reporting.NameSegment("offset")); + yield return error; + } + + foreach (var error in Verification.Verify_long(that.Small)) + { + error.PrependSegment( + new Reporting.NameSegment("small")); + yield return error; + } + + foreach (var error in Verification.Verify_long(that.ByNegative)) + { + error.PrependSegment( + new Reporting.NameSegment("byNegative")); + yield return error; + } + + foreach (var error in Verification.Verify_long(that.Negative)) + { + error.PrependSegment( + new Reporting.NameSegment("negative")); + yield return error; + } + + foreach (var error in Verification.Verify_long(that.Deviation)) + { + error.PrependSegment( + new Reporting.NameSegment("deviation")); + yield return error; + } + + foreach (var error in Verification.Verify_long(that.Alignment)) + { + error.PrependSegment( + new Reporting.NameSegment("alignment")); + yield return error; + } + + if (that.OptionalNumber.HasValue) + { + foreach ( + var error in Verification.Verify_long( + that.OptionalNumber.Value)) + { + error.PrependSegment( + new Reporting.NameSegment("optionalNumber")); + yield return error; + } + } } } // private class Transformer @@ -248,6 +302,11 @@ Our.ISomething that public static IEnumerable VerifyEvenInt ( long that) { + foreach (var error in Verify_long(that)) + { + yield return error; + } + if (!((Common.FloorMod(that, 2)) == (0))) { yield return new Reporting.Error( @@ -255,6 +314,36 @@ Our.ISomething that "Even"); } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/xmlization.cs index d957d6a70..349e8026a 100644 --- a/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/arithmetic/expected_output/dummy/xmlization.cs @@ -213,6 +213,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -323,7 +358,8 @@ private static Reporting.Error DuplicatePropertyError(string elementName) SomethingFromSequence, "something")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_string = ( AsText(ReadContentAsString, "")); @@ -813,7 +849,16 @@ internal static void WriteIClass( } private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_string = ( (that, writer) => writer.WriteValue(that)); diff --git a/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..cd5fae4cf --- /dev/null +++ b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,362 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestChildANumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.ChildAFrom( + LoadTheFirstExpected("ChildA")); + + instance.Number = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "number"); + } + } // public void TestChildANumberOutsideTheSafeRange + + [Test] + public void TestChildANumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.ChildAFrom( + LoadTheFirstExpected("ChildA")); + + instance.Number = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.ChildAFrom, + Our.Xmlization.Deserialize.ChildAFrom); + } + } // public void TestChildANumberAtTheBoundsOfTheSafeRange + + [Test] + public void TestChildAAOnlyOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.ChildAFrom( + LoadTheFirstExpected("ChildA")); + + instance.AOnly = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "aOnly"); + } + } // public void TestChildAAOnlyOutsideTheSafeRange + + [Test] + public void TestChildAAOnlyAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.ChildAFrom( + LoadTheFirstExpected("ChildA")); + + instance.AOnly = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.ChildAFrom, + Our.Xmlization.Deserialize.ChildAFrom); + } + } // public void TestChildAAOnlyAtTheBoundsOfTheSafeRange + + [Test] + public void TestChildBNumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.ChildBFrom( + LoadTheFirstExpected("ChildB")); + + instance.Number = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "number"); + } + } // public void TestChildBNumberOutsideTheSafeRange + + [Test] + public void TestChildBNumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.ChildBFrom( + LoadTheFirstExpected("ChildB")); + + instance.Number = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.ChildBFrom, + Our.Xmlization.Deserialize.ChildBFrom); + } + } // public void TestChildBNumberAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingNumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "number"); + } + } // public void TestSomethingNumberOutsideTheSafeRange + + [Test] + public void TestSomethingNumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingNumberAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/jsonization.cs index 56c25f459..1b3e3b794 100644 --- a/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -873,48 +924,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/verification.cs index 5bdaa6338..3f0375a52 100644 --- a/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/verification.cs @@ -158,8 +158,19 @@ private class Transformer Our.IChildA that ) { - // No verification has been defined for ChildA. - yield break; + foreach (var error in Verification.Verify_long(that.Number)) + { + error.PrependSegment( + new Reporting.NameSegment("number")); + yield return error; + } + + foreach (var error in Verification.Verify_long(that.AOnly)) + { + error.PrependSegment( + new Reporting.NameSegment("aOnly")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -167,8 +178,12 @@ Our.IChildA that Our.IChildB that ) { - // No verification has been defined for ChildB. - yield break; + foreach (var error in Verification.Verify_long(that.Number)) + { + error.PrependSegment( + new Reporting.NameSegment("number")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -223,6 +238,13 @@ Our.ISomething that "Number must be between minus one thousand and one thousand"); } + foreach (var error in Verification.Verify_long(that.Number)) + { + error.PrependSegment( + new Reporting.NameSegment("number")); + yield return error; + } + foreach (var error in Verification.Verify(that.Parent)) { error.PrependSegment( @@ -245,6 +267,36 @@ Our.ISomething that yield return error; } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/xmlization.cs index 219c10a67..8397aa604 100644 --- a/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/assert_statements/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -461,7 +496,8 @@ out Reporting.Error? error SomethingFromSequence, "something")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_string = ( AsText(ReadContentAsString, "")); @@ -1293,7 +1329,16 @@ internal static void WriteIClass( } private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_string = ( (that, writer) => writer.WriteValue(that)); diff --git a/dev/test_data/main/csharp/expected/assignment_targets/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/assignment_targets/expected_output/dummy/jsonization.cs index bea733a40..56d6c74c7 100644 --- a/dev/test_data/main/csharp/expected/assignment_targets/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/assignment_targets/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -677,28 +647,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/collections_in_returns/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/collections_in_returns/expected_output/dummy/jsonization.cs index 29134cfab..c4bd434c4 100644 --- a/dev/test_data/main/csharp/expected/collections_in_returns/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/collections_in_returns/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -721,28 +691,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..a8e422e90 --- /dev/null +++ b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSomethingCountOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Count = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "count"); + } + } // public void TestSomethingCountOutsideTheSafeRange + + [Test] + public void TestSomethingCountAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Count = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingCountAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/jsonization.cs index 3a3b2600f..0a1b7577f 100644 --- a/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } + + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -477,48 +528,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/verification.cs index 15671ed58..187f30e50 100644 --- a/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/verification.cs @@ -147,6 +147,13 @@ Our.ISomething that "Invariant violated:\n" + "Flag implies the other flag"); } + + foreach (var error in Verification.Verify_long(that.Count)) + { + error.PrependSegment( + new Reporting.NameSegment("count")); + yield return error; + } } } // private class Transformer @@ -163,6 +170,36 @@ Our.ISomething that yield return error; } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/xmlization.cs index 02039ea8b..6c6377dd4 100644 --- a/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/comparisons/expected_output/dummy/xmlization.cs @@ -222,6 +222,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -338,7 +373,8 @@ private static Reporting.Error DuplicatePropertyError(string elementName) AsText(ReadContentAsBoolean)); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_double = ( AsText(ReadContentAsDouble)); @@ -725,7 +761,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_double = ( (that, writer) => writer.WriteValue(that)); diff --git a/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..1d44d5e9c --- /dev/null +++ b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSomethingSomeIntOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.SomeInt = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "someInt"); + } + } // public void TestSomethingSomeIntOutsideTheSafeRange + + [Test] + public void TestSomethingSomeIntAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.SomeInt = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingSomeIntAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/jsonization.cs index 1186d96a6..cd8201957 100644 --- a/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } + + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -477,48 +528,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/verification.cs index 2d132ab2e..8aed8ff31 100644 --- a/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/verification.cs @@ -130,6 +130,11 @@ var error in Verification.VerifyNonemptybytes( public static IEnumerable VerifyPositiveint ( long that) { + foreach (var error in Verify_long(that)) + { + yield return error; + } + if (!(that > 0)) { yield return new Reporting.Error( @@ -179,6 +184,36 @@ var error in Verification.VerifyNonemptybytes( "At least one byte"); } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/xmlization.cs index 6f563739f..c9b4e0cf1 100644 --- a/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/constrained_primitives/expected_output/dummy/xmlization.cs @@ -256,6 +256,41 @@ private static byte[] ReadContentAsBytes(Xml.XmlReader reader) reader); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -369,7 +404,8 @@ private static Reporting.Error DuplicatePropertyError(string elementName) AsText(ReadContentAsBoolean)); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_double = ( AsText(ReadContentAsDouble)); @@ -759,7 +795,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_double = ( (that, writer) => writer.WriteValue(that)); diff --git a/dev/test_data/main/csharp/expected/custom_serialization_names/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/custom_serialization_names/expected_output/dummy/jsonization.cs index d65f9189e..e9f8755a4 100644 --- a/dev/test_data/main/csharp/expected/custom_serialization_names/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/custom_serialization_names/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -413,28 +383,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..165fc8741 --- /dev/null +++ b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,286 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestLeafValueOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.LeafFrom( + LoadTheFirstExpected("Leaf")); + + instance.Value = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "value"); + } + } // public void TestLeafValueOutsideTheSafeRange + + [Test] + public void TestLeafValueAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.LeafFrom( + LoadTheFirstExpected("Leaf")); + + instance.Value = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.LeafFrom, + Our.Xmlization.Deserialize.LeafFrom); + } + } // public void TestLeafValueAtTheBoundsOfTheSafeRange + + [Test] + public void TestBlossomValueOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.BlossomFrom( + LoadTheFirstExpected("Blossom")); + + instance.Value = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "value"); + } + } // public void TestBlossomValueOutsideTheSafeRange + + [Test] + public void TestBlossomValueAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.BlossomFrom( + LoadTheFirstExpected("Blossom")); + + instance.Value = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.BlossomFrom, + Our.Xmlization.Deserialize.BlossomFrom); + } + } // public void TestBlossomValueAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/common.cs index a0b3ba772..d50d971c9 100644 --- a/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/common.cs @@ -24,6 +24,58 @@ public static int Len(System.Runtime.CompilerServices.ITuple tuple) return tuple.Length; } } // public static class TupleHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/jsonization.cs index 3a6311af2..bad21a53e 100644 --- a/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } + + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -1306,48 +1357,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/verification.cs index 679c30f13..5c5109b80 100644 --- a/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/verification.cs @@ -60,8 +60,12 @@ Our.IBranch that Our.ILeaf that ) { - // No verification has been defined for Leaf. - yield break; + foreach (var error in Verification.Verify_long(that.Value)) + { + error.PrependSegment( + new Reporting.NameSegment("value")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -69,8 +73,12 @@ Our.ILeaf that Our.IBlossom that ) { - // No verification has been defined for Blossom. - yield break; + foreach (var error in Verification.Verify_long(that.Value)) + { + error.PrependSegment( + new Reporting.NameSegment("value")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -138,6 +146,36 @@ Our.IContainer that yield return error; } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/xmlization.cs index 81a6f7c74..29c1ddfde 100644 --- a/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/deep_class_hierarchy/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -413,7 +448,8 @@ out Reporting.Error? error AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_INode = ( AsElement( @@ -1849,7 +1885,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_INode = ( WriteIClass); diff --git a/dev/test_data/main/csharp/expected/empty_class/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/empty_class/expected_output/dummy/jsonization.cs index be19ca97d..7f39fa4c8 100644 --- a/dev/test_data/main/csharp/expected/empty_class/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/empty_class/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -393,28 +363,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/enum/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/enum/expected_output/dummy/jsonization.cs index cc0142148..38cc842a5 100644 --- a/dev/test_data/main/csharp/expected/enum/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/enum/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -465,28 +435,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/float_only/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/float_only/expected_output/dummy/jsonization.cs index 8ddf27390..6eca84864 100644 --- a/dev/test_data/main/csharp/expected/float_only/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/float_only/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -602,28 +572,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..b18b67cfe --- /dev/null +++ b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSomethingNumbersOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Numbers = new List() { 0L, value }; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "numbers[1]"); + } + } // public void TestSomethingNumbersOutsideTheSafeRange + + [Test] + public void TestSomethingNumbersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Numbers = new List() { 0L, value }; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingNumbersAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/common.cs index eaf1c306b..ca91a0ae6 100644 --- a/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/common.cs @@ -123,6 +123,58 @@ public static long FloorMod(long dividend, long divisor) return remainder; } + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/jsonization.cs index a22aef284..30581ba74 100644 --- a/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -688,48 +739,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/verification.cs index dd76cfe13..487526fad 100644 --- a/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/verification.cs @@ -449,6 +449,13 @@ Our.ISomething that yield return error; } + foreach (var error in Verification.Verify_ListOf_long(that.Numbers)) + { + error.PrependSegment( + new Reporting.NameSegment("numbers")); + yield return error; + } + foreach (var error in Verification.Verify_ListOf_IItem(that.Items)) { error.PrependSegment( @@ -486,6 +493,55 @@ Our.ISomething that } } + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + + /// + /// Verify the items of recursively. + /// + private static IEnumerable Verify_ListOf_long( + List that) + { + int index = 0; + foreach (var item in that) + { + foreach (var error in Verification.Verify_long(item)) + { + error.PrependSegment( + new Reporting.IndexSegment(index)); + yield return error; + } + index++; + } + } + /// /// Verify the items of recursively. /// diff --git a/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/xmlization.cs index d9438840f..bfec75c09 100644 --- a/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/for_statements/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -451,7 +486,8 @@ out Reporting.Error? error Stringification.KindFromString)); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader> Read_ListOf_long = ( AsList( @@ -1071,7 +1107,16 @@ internal static void WriteIClass( Stringification.ToString)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter> Write_ListOf_long = ( WriteList( diff --git a/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..ec029cebc --- /dev/null +++ b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,324 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestChildAAOnlyOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.ChildAFrom( + LoadTheFirstExpected("ChildA")); + + instance.AOnly = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "aOnly"); + } + } // public void TestChildAAOnlyOutsideTheSafeRange + + [Test] + public void TestChildAAOnlyAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.ChildAFrom( + LoadTheFirstExpected("ChildA")); + + instance.AOnly = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.ChildAFrom, + Our.Xmlization.Deserialize.ChildAFrom); + } + } // public void TestChildAAOnlyAtTheBoundsOfTheSafeRange + + [Test] + public void TestChildBBOnlyOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.ChildBFrom( + LoadTheFirstExpected("ChildB")); + + instance.BOnly = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "bOnly"); + } + } // public void TestChildBBOnlyOutsideTheSafeRange + + [Test] + public void TestChildBBOnlyAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.ChildBFrom( + LoadTheFirstExpected("ChildB")); + + instance.BOnly = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.ChildBFrom, + Our.Xmlization.Deserialize.ChildBFrom); + } + } // public void TestChildBBOnlyAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingNumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "number"); + } + } // public void TestSomethingNumberOutsideTheSafeRange + + [Test] + public void TestSomethingNumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingNumberAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/common.cs index eaf1c306b..ca91a0ae6 100644 --- a/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/common.cs @@ -123,6 +123,58 @@ public static long FloorMod(long dividend, long divisor) return remainder; } + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/jsonization.cs index 06bc71e5d..cfafc2fa3 100644 --- a/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -1131,48 +1182,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/verification.cs index a08dfbbe8..fa6049b4f 100644 --- a/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/verification.cs @@ -449,8 +449,12 @@ Our.IItem that Our.IChildA that ) { - // No verification has been defined for ChildA. - yield break; + foreach (var error in Verification.Verify_long(that.AOnly)) + { + error.PrependSegment( + new Reporting.NameSegment("aOnly")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -458,8 +462,12 @@ Our.IChildA that Our.IChildB that ) { - // No verification has been defined for ChildB. - yield break; + foreach (var error in Verification.Verify_long(that.BOnly)) + { + error.PrependSegment( + new Reporting.NameSegment("bOnly")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -630,6 +638,13 @@ Our.ISomething that yield return error; } + foreach (var error in Verification.Verify_long(that.Number)) + { + error.PrependSegment( + new Reporting.NameSegment("number")); + yield return error; + } + foreach (var error in Verification.Verify(that.Item)) { error.PrependSegment( @@ -689,6 +704,36 @@ var error in Verification.Verify_ListOf_IParent( } } + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + /// /// Verify the items of recursively. /// diff --git a/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/xmlization.cs index 15124b515..8eb10f364 100644 --- a/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/if_statements/expected_output/dummy/xmlization.cs @@ -212,6 +212,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -534,7 +569,8 @@ out Reporting.Error? error AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader> Read_ListOf_IParent = ( AsList( @@ -1716,7 +1752,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter> Write_ListOf_IParent = ( WriteList( diff --git a/dev/test_data/main/csharp/expected/implementation_specific/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/implementation_specific/expected_output/dummy/jsonization.cs index 26e95c032..c4cdeb641 100644 --- a/dev/test_data/main/csharp/expected/implementation_specific/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/implementation_specific/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -895,28 +865,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..8272768f2 --- /dev/null +++ b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,286 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSomethingNumbersOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Numbers = new List() { 0L, value }; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "numbers[1]"); + } + } // public void TestSomethingNumbersOutsideTheSafeRange + + [Test] + public void TestSomethingNumbersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Numbers = new List() { 0L, value }; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingNumbersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingCountOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Count = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "count"); + } + } // public void TestSomethingCountOutsideTheSafeRange + + [Test] + public void TestSomethingCountAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Count = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingCountAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/common.cs index 60bcf5b7d..44c4c96e8 100644 --- a/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/common.cs @@ -63,6 +63,58 @@ public static long FloorMod(long dividend, long divisor) return remainder; } + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/jsonization.cs index 6124e84b4..48310acc2 100644 --- a/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -545,48 +596,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/verification.cs index dbfcec524..29869e1c4 100644 --- a/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/verification.cs @@ -84,11 +84,25 @@ long count /// /// 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. + /// public static bool NoSevenFrom( List numbers, long start ) { + if (start < 0) + { + return false; + } + if (start >= numbers.Count) + { + return true; + } for (long i = start; i < numbers.Count; i++) { long number = numbers[checked((int)i)]; @@ -231,6 +245,30 @@ Our.ISomething that "Invariant violated:\n" + "No number is seven"); } + + foreach (var error in Verification.Verify_ListOf_long(that.Numbers)) + { + error.PrependSegment( + new Reporting.NameSegment("numbers")); + yield return error; + } + + foreach (var error in Verification.Verify_long(that.Count)) + { + error.PrependSegment( + new Reporting.NameSegment("count")); + yield return error; + } + + if (that.MaybeCount.HasValue) + { + foreach (var error in Verification.Verify_long(that.MaybeCount.Value)) + { + error.PrependSegment( + new Reporting.NameSegment("maybeCount")); + yield return error; + } + } } } // private class Transformer @@ -247,6 +285,55 @@ Our.ISomething that yield return error; } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + + /// + /// Verify the items of recursively. + /// + private static IEnumerable Verify_ListOf_long( + List that) + { + int index = 0; + foreach (var item in that) + { + foreach (var error in Verification.Verify_long(item)) + { + error.PrependSegment( + new Reporting.IndexSegment(index)); + yield return error; + } + index++; + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/xmlization.cs index 7e3aeb47c..6f1887b78 100644 --- a/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/int_length_interplay/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -385,7 +420,8 @@ out Reporting.Error? error SomethingFromSequence, "something")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader> Read_ListOf_long = ( AsList( @@ -808,7 +844,16 @@ internal static void WriteIClass( } private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter> Write_ListOf_long = ( WriteList( diff --git a/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..d05e1ffd7 --- /dev/null +++ b/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,160 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy/common.cs index 75f2f09ae..9debfcf49 100644 --- a/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy/common.cs @@ -211,6 +211,58 @@ public static string LStrip(string text, string chars) } } // public static class StringHelpers + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + /// /// Parse as a safe integer. /// @@ -222,7 +274,13 @@ public static string LStrip(string text, string chars) /// We accept only an optional sign followed by the ASCII digits, and only /// the safe integers, i.e., the integers within -(2^53 - 1) /// and 2^53 - 1, which a double-precision floating-point number - /// represents exactly. This way, all the SDKs behave the same. + /// represents exactly. + /// + /// + /// We check the safe range even though a long could hold larger + /// integers. TypeScript represents the integers as 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. /// /// /// to be parsed @@ -262,8 +320,7 @@ public static long ParseSafeInt(string text) // overflow a long. value = value * 10 + (character - '0'); - // NOTE: This is 2^53 - 1. - if (value > 9007199254740991L) + if (!IsSafeInteger(value)) { throw new System.ArgumentException( $"Expected a safe integer, but got a text out of its range: {text}"); diff --git a/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy/jsonization.cs index cbbb42c46..a856ba88c 100644 --- a/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/int_parsing/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -467,28 +437,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/is_instance/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/is_instance/expected_output/dummy/jsonization.cs index f731f2fe8..d581ba0da 100644 --- a/dev/test_data/main/csharp/expected/is_instance/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/is_instance/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -1877,28 +1847,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..ac8e6daf3 --- /dev/null +++ b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,407 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + [Test] + public void TestSomethingValueWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Value = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "value[0]"); + } + } // public void TestSomethingValueWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingValueWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Value = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertAccepted(instance); + } + } // public void TestSomethingValueWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingValuesWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Values = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "values[0]"); + } + } // public void TestSomethingValuesWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingValuesWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Values = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertAccepted(instance); + } + } // public void TestSomethingValuesWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingMappingWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Mapping = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "mapping[\"key\"]"); + } + } // public void TestSomethingMappingWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingMappingWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Mapping = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertAccepted(instance); + } + } // public void TestSomethingMappingWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingMappingWithConstrainedKeyWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.MappingWithConstrainedKey = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "mappingWithConstrainedKey[\"key\"]"); + } + } // public void TestSomethingMappingWithConstrainedKeyWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingMappingWithConstrainedKeyWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.MappingWithConstrainedKey = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertAccepted(instance); + } + } // public void TestSomethingMappingWithConstrainedKeyWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingOptionalValueWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalValue = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "optionalValue[0]"); + } + } // public void TestSomethingOptionalValueWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingOptionalValueWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalValue = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertAccepted(instance); + } + } // public void TestSomethingOptionalValueWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingOptionalValuesWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalValues = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "optionalValues[0]"); + } + } // public void TestSomethingOptionalValuesWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingOptionalValuesWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalValues = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertAccepted(instance); + } + } // public void TestSomethingOptionalValuesWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingOptionalMappingWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalMapping = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "optionalMapping[\"key\"]"); + } + } // public void TestSomethingOptionalMappingWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingOptionalMappingWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalMapping = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertAccepted(instance); + } + } // public void TestSomethingOptionalMappingWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void Test_JsonValueFrom_checks_the_safe_integer_range() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var exception = Assert.Throws( + () => Our.Jsonization.Deserialize.JsonValueFrom( + new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); + Assert.AreEqual( + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception!.Cause); + Assert.AreEqual("[0]", exception!.Path); + } + + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + Assert.DoesNotThrow( + () => Our.Jsonization.Deserialize.JsonValueFrom( + new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); + } + } // public void Test_JsonValueFrom_checks_the_safe_integer_range + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy.Tests/TestXmlRpc.cs b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy.Tests/TestXmlRpc.cs index 3a028501a..993f20c05 100644 --- a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy.Tests/TestXmlRpc.cs +++ b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy.Tests/TestXmlRpc.cs @@ -379,6 +379,44 @@ public void Test_serialize_rejects_non_finite_number() } } + [Test] + public void Test_serialize_rejects_an_integer_outside_the_safe_range() + { + foreach (long value in new[] { 9007199254740992L, -9007199254740992L }) + { + var exception = Assert.Throws( + () => SerializeToString( + new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); + Assert.AreEqual( + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception!.Cause); + Assert.AreEqual("[0]", exception!.Path); + } + } + + [Test] + public void Test_round_trip_an_integer_at_the_bounds_of_the_safe_range() + { + foreach (long value in new[] { 9007199254740991L, -9007199254740991L }) + { + string text = SerializeToString(Nodes.JsonValue.Create(value)); + + Nodes.JsonNode? roundTripped = DeserializeFromString( + text, out Our.Reporting.Error? error); + + Assert.IsNull(error); + Assert.IsNotNull(roundTripped); + + // NOTE (mristin): + // XML-RPC carries every JSON number in a , which represents + // a safe integer exactly. + bool ok = roundTripped!.AsValue().TryGetValue(out double gotValue); + Assert.IsTrue(ok); + Assert.AreEqual((double)value, gotValue); + } + } + [Test] public void Test_deserialize_rejects_non_finite_number() { diff --git a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/jsonization.cs index 3cea62cb6..b76f2dffc 100644 --- a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -293,18 +263,10 @@ private static bool CheckIsJsonAble( return true; case Nodes.JsonValue jsonValue: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value. A conformant parser can never produce one, but - // the caller gives us a node which may have been constructed - // programmatically. - if ( - jsonValue.TryGetValue(out double number) - && !System.Double.IsFinite(number)) + string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); + if (cause != null) { - error = new Reporting.Error( - $"Expected a JSON-able value, but got the number {number}, " + - "which is neither finite nor representable in JSON"); + error = new Reporting.Error(cause); return false; } return true; @@ -599,6 +561,102 @@ public SerializationFailure(Reporting.Error error) /// public static class Deserialize { + /// + /// Deserialize a JSON-able value from . + /// + /// + /// A JSON-able value is, recursively, exactly as JSON itself is defined: + /// a null (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, independent copy of . + /// + /// JSON node to be parsed + /// + /// Thrown when is not a JSON-able + /// value. + /// + public static Nodes.JsonNode? JsonValueFrom( + Nodes.JsonNode? node) + { + Nodes.JsonNode? result = DeserializeImplementation.JsonValueFrom( + node, + out Reporting.Error? error); + if (error != null) + { + throw new Jsonization.Exception( + Reporting.GenerateJsonPath(error.PathSegments), + error.Cause); + } + return result; + } + + /// + /// Deserialize a JSON-able array from . + /// + /// + /// A JSON-able value is, recursively, exactly as JSON itself is defined: + /// a null (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, independent copy of . + /// + /// JSON node to be parsed + /// + /// Thrown when is not a JSON-able + /// array. + /// + public static Nodes.JsonArray JsonArrayFrom( + Nodes.JsonNode node) + { + Nodes.JsonArray result = DeserializeImplementation.JsonArrayFrom( + node, + out Reporting.Error? error); + if (error != null) + { + throw new Jsonization.Exception( + Reporting.GenerateJsonPath(error.PathSegments), + error.Cause); + } + return result; + } + + /// + /// Deserialize a JSON-able object from . + /// + /// + /// A JSON-able value is, recursively, exactly as JSON itself is defined: + /// a null (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, independent copy of . + /// + /// JSON node to be parsed + /// + /// Thrown when is not a JSON-able + /// object. + /// + public static Nodes.JsonObject JsonObjectFrom( + Nodes.JsonNode node) + { + Nodes.JsonObject result = DeserializeImplementation.JsonObjectFrom( + node, + out Reporting.Error? error); + if (error != null) + { + throw new Jsonization.Exception( + Reporting.GenerateJsonPath(error.PathSegments), + error.Cause); + } + return result; + } + /// /// Deserialize an instance of Something from . /// @@ -652,28 +710,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. @@ -771,18 +807,10 @@ private static void CheckIsJsonAbleForSerialization(Nodes.JsonNode that) break; case Nodes.JsonValue jsonValue: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though Nodes.JsonValue holds one happily - // if it was constructed programmatically. - if ( - jsonValue.TryGetValue(out double number) - && !System.Double.IsFinite(number)) + string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); + if (cause != null) { - throw new SerializationFailure( - new Reporting.Error( - $"Expected a JSON-able value, but got the number {number}, " + - "which is neither finite nor representable in JSON")); + throw new SerializationFailure(new Reporting.Error(cause)); } break; @@ -803,7 +831,7 @@ private static void CheckIsJsonAbleForSerialization(Nodes.JsonNode that) /// Thrown if contains a non-finite number anywhere, /// at any depth. /// - private static Nodes.JsonNode? SerializeJsonValue(Nodes.JsonNode? that) + internal static Nodes.JsonNode? SerializeJsonValue(Nodes.JsonNode? that) { if (that == null) { @@ -990,6 +1018,32 @@ public static Nodes.JsonObject ToJsonObject(Our.IClass that) failure.Error.Cause); } } + + /// + /// Serialize the JSON-able value into a JSON node. + /// + /// + /// The result is a deep, independent copy of . + /// A null is a JSON null. + /// + /// + /// Thrown when contains, at any depth, an infinity, + /// a not-a-number or an integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] + /// + public static Nodes.JsonNode? JsonValueToJsonNode(Nodes.JsonNode? that) + { + try + { + return Transformer.SerializeJsonValue(that); + } + catch (SerializationFailure failure) + { + throw new SerializationException( + Reporting.GenerateCSharpPath(failure.Error.PathSegments), + failure.Error.Cause); + } + } } // public static class Serialize } // public static class Jsonization } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/jsonvalueverification.cs b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/jsonvalueverification.cs index 6eb114d83..e8b97366c 100644 --- a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/jsonvalueverification.cs +++ b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/jsonvalueverification.cs @@ -15,6 +15,74 @@ namespace dummy /// public static class JsonValueVerification { + /// + /// Report that the integer of a JSON-able value is + /// not a safe integer. + /// + /// + /// See for what a safe + /// integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + private static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Check that , if it is a number at all, is + /// a number of a JSON-able value. + /// + /// + /// JSON knows a single numeric type, which all the other SDKs read into + /// a 64-bit floating-point number. 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. + /// + /// JSON value to be checked + /// + /// Cause why is not JSON-able, or null + /// if it is + /// + internal static string? CheckJsonAbleNumber(Nodes.JsonValue jsonValue) + { + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. + if (jsonValue.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + return UnsafeIntegerMessage(integer); + } + } + else if (jsonValue.TryGetValue(out ulong unsignedInteger)) + { + if (!Common.IsSafeInteger(unsignedInteger)) + { + return UnsafeIntegerMessage(unsignedInteger); + } + } + else if (jsonValue.TryGetValue(out double number)) + { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number. A conformant parser + // never gives us one, but the node may have been constructed + // programmatically. + if (!System.Double.IsFinite(number)) + { + return $"Expected a JSON-able value, but got the number {number}, " + + "which is neither finite nor representable in JSON"; + } + } + + return null; + } + /// /// Represent the expected top-level shape of a JSON-able value. /// @@ -38,14 +106,12 @@ public enum ExpectedShape } /// - /// Verify that holds no non-finite number anywhere, - /// at any depth. + /// Verify that holds no number anywhere, at any depth, + /// which is not a number of a JSON-able value. /// /// - /// JSON knows neither an infinity nor a not-a-number -- yet - /// holds either happily if it was put - /// together programmatically. A null is a JSON null, and hence - /// a JSON-able value. + /// See for what a JSON-able number is. + /// A null is a JSON null, and hence a JSON-able value. /// private static IEnumerable VerifyRecursively( Nodes.JsonNode that) @@ -91,17 +157,12 @@ public enum ExpectedShape break; case Nodes.JsonValue jsonValue: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though Nodes.JsonValue holds one happily - // if it was constructed programmatically. - if ( - jsonValue.TryGetValue(out double number) - && !System.Double.IsFinite(number)) { - yield return new Reporting.Error( - $"Expected a JSON-able value, but got the number {number}, " + - "which is neither finite nor representable in JSON"); + string? cause = CheckJsonAbleNumber(jsonValue); + if (cause != null) + { + yield return new Reporting.Error(cause); + } } break; @@ -113,8 +174,9 @@ public enum ExpectedShape /// /// Verify that is JSON-able (recursively - /// a null, a boolean, a finite number, a string, an array or an object of - /// JSON-able values -- never a non-finite number, at any depth) and, if + /// a null, a boolean, a number, a string, an array or an object of + /// JSON-able values -- never an infinity, a not-a-number or an integer outside + /// the safe integer range [-2^53 + 1, 2^53 - 1], at any depth) and, if /// is not /// , that its top-level shape matches. /// diff --git a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/xmlrpc.cs b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/xmlrpc.cs index 90d6e95e9..13101c5c3 100644 --- a/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/xmlrpc.cs +++ b/dev/test_data/main/csharp/expected/json_types/expected_output/dummy/xmlrpc.cs @@ -721,6 +721,17 @@ public static void SerializeValueTo( break; case Nodes.JsonValue jsonValue: + // NOTE (mristin): + // We check a number exactly as the verification and the JSON + // de/serialization do. Otherwise, an integer outside the safe integer + // range would be quietly written as a different double below. + string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); + if (cause != null) + { + throw new SerializationFailure( + new Reporting.Error(cause)); + } + if (jsonValue.TryGetValue(out bool boolValue)) { writer.WriteStartElement("boolean", ""); @@ -733,22 +744,28 @@ public static void SerializeValueTo( writer.WriteString(boolValue ? "1" : "0"); writer.WriteEndElement(); } + else if (jsonValue.TryGetValue(out long integer)) + { + // NOTE (mristin): + // XML-RPC carries every JSON number in a , and the check + // above made sure that the integer is represented exactly. + writer.WriteStartElement("double", ""); + writer.WriteValue(integer); + writer.WriteEndElement(); + } + else if (jsonValue.TryGetValue(out ulong unsignedInteger)) + { + writer.WriteStartElement("double", ""); + writer.WriteValue((long)unsignedInteger); + writer.WriteEndElement(); + } else if (jsonValue.TryGetValue(out double doubleValue)) { // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither - // is a JSON-able value. XmlWriter would happily write "INF" or - // "NaN" -- the xs:double spellings -- which - // DeserializeDoubleTextFrom deliberately refuses to read back. - if (!System.Double.IsFinite(doubleValue)) - { - throw new SerializationFailure( - new Reporting.Error( - "Expected a JSON-able value, but got the number " + - $"{doubleValue}, which is neither finite nor " + - "representable in JSON")); - } - + // The check above refused an infinity and a not-a-number, which + // XmlWriter would otherwise happily write as "INF" or "NaN" -- the + // xs:double spellings -- and which DeserializeDoubleTextFrom + // deliberately refuses to read back. writer.WriteStartElement("double", ""); writer.WriteValue(doubleValue); writer.WriteEndElement(); diff --git a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..7b835fd61 --- /dev/null +++ b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,215 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + [Test] + public void TestSomethingOptionalObjectWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalObject = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "optionalObject[\"key\"]"); + } + } // public void TestSomethingOptionalObjectWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingOptionalObjectWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalObject = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertAccepted(instance); + } + } // public void TestSomethingOptionalObjectWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void Test_JsonValueFrom_checks_the_safe_integer_range() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var exception = Assert.Throws( + () => Our.Jsonization.Deserialize.JsonValueFrom( + new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); + Assert.AreEqual( + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception!.Cause); + Assert.AreEqual("[0]", exception!.Path); + } + + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + Assert.DoesNotThrow( + () => Our.Jsonization.Deserialize.JsonValueFrom( + new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); + } + } // public void Test_JsonValueFrom_checks_the_safe_integer_range + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy.Tests/TestXmlRpc.cs b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy.Tests/TestXmlRpc.cs index 3a028501a..993f20c05 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy.Tests/TestXmlRpc.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy.Tests/TestXmlRpc.cs @@ -379,6 +379,44 @@ public void Test_serialize_rejects_non_finite_number() } } + [Test] + public void Test_serialize_rejects_an_integer_outside_the_safe_range() + { + foreach (long value in new[] { 9007199254740992L, -9007199254740992L }) + { + var exception = Assert.Throws( + () => SerializeToString( + new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); + Assert.AreEqual( + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception!.Cause); + Assert.AreEqual("[0]", exception!.Path); + } + } + + [Test] + public void Test_round_trip_an_integer_at_the_bounds_of_the_safe_range() + { + foreach (long value in new[] { 9007199254740991L, -9007199254740991L }) + { + string text = SerializeToString(Nodes.JsonValue.Create(value)); + + Nodes.JsonNode? roundTripped = DeserializeFromString( + text, out Our.Reporting.Error? error); + + Assert.IsNull(error); + Assert.IsNotNull(roundTripped); + + // NOTE (mristin): + // XML-RPC carries every JSON number in a , which represents + // a safe integer exactly. + bool ok = roundTripped!.AsValue().TryGetValue(out double gotValue); + Assert.IsTrue(ok); + Assert.AreEqual((double)value, gotValue); + } + } + [Test] public void Test_deserialize_rejects_non_finite_number() { diff --git a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/common.cs index a0b3ba772..d50d971c9 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/common.cs @@ -24,6 +24,58 @@ public static int Len(System.Runtime.CompilerServices.ITuple tuple) return tuple.Length; } } // public static class TupleHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/jsonization.cs index 57aa1bb96..4a387473b 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -473,18 +443,10 @@ private static bool CheckIsJsonAble( return true; case Nodes.JsonValue jsonValue: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value. A conformant parser can never produce one, but - // the caller gives us a node which may have been constructed - // programmatically. - if ( - jsonValue.TryGetValue(out double number) - && !System.Double.IsFinite(number)) + string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); + if (cause != null) { - error = new Reporting.Error( - $"Expected a JSON-able value, but got the number {number}, " + - "which is neither finite nor representable in JSON"); + error = new Reporting.Error(cause); return false; } return true; @@ -745,6 +707,102 @@ public SerializationFailure(Reporting.Error error) /// public static class Deserialize { + /// + /// Deserialize a JSON-able value from . + /// + /// + /// A JSON-able value is, recursively, exactly as JSON itself is defined: + /// a null (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, independent copy of . + /// + /// JSON node to be parsed + /// + /// Thrown when is not a JSON-able + /// value. + /// + public static Nodes.JsonNode? JsonValueFrom( + Nodes.JsonNode? node) + { + Nodes.JsonNode? result = DeserializeImplementation.JsonValueFrom( + node, + out Reporting.Error? error); + if (error != null) + { + throw new Jsonization.Exception( + Reporting.GenerateJsonPath(error.PathSegments), + error.Cause); + } + return result; + } + + /// + /// Deserialize a JSON-able array from . + /// + /// + /// A JSON-able value is, recursively, exactly as JSON itself is defined: + /// a null (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, independent copy of . + /// + /// JSON node to be parsed + /// + /// Thrown when is not a JSON-able + /// array. + /// + public static Nodes.JsonArray JsonArrayFrom( + Nodes.JsonNode node) + { + Nodes.JsonArray result = DeserializeImplementation.JsonArrayFrom( + node, + out Reporting.Error? error); + if (error != null) + { + throw new Jsonization.Exception( + Reporting.GenerateJsonPath(error.PathSegments), + error.Cause); + } + return result; + } + + /// + /// Deserialize a JSON-able object from . + /// + /// + /// A JSON-able value is, recursively, exactly as JSON itself is defined: + /// a null (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, independent copy of . + /// + /// JSON node to be parsed + /// + /// Thrown when is not a JSON-able + /// object. + /// + public static Nodes.JsonObject JsonObjectFrom( + Nodes.JsonNode node) + { + Nodes.JsonObject result = DeserializeImplementation.JsonObjectFrom( + node, + out Reporting.Error? error); + if (error != null) + { + throw new Jsonization.Exception( + Reporting.GenerateJsonPath(error.PathSegments), + error.Cause); + } + return result; + } + /// /// Deserialize an instance of Something from . /// @@ -798,28 +856,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. @@ -1048,18 +1084,10 @@ private static void CheckIsJsonAbleForSerialization(Nodes.JsonNode that) break; case Nodes.JsonValue jsonValue: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though Nodes.JsonValue holds one happily - // if it was constructed programmatically. - if ( - jsonValue.TryGetValue(out double number) - && !System.Double.IsFinite(number)) + string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); + if (cause != null) { - throw new SerializationFailure( - new Reporting.Error( - $"Expected a JSON-able value, but got the number {number}, " + - "which is neither finite nor representable in JSON")); + throw new SerializationFailure(new Reporting.Error(cause)); } break; @@ -1080,7 +1108,7 @@ private static void CheckIsJsonAbleForSerialization(Nodes.JsonNode that) /// Thrown if contains a non-finite number anywhere, /// at any depth. /// - private static Nodes.JsonNode? SerializeJsonValue(Nodes.JsonNode? that) + internal static Nodes.JsonNode? SerializeJsonValue(Nodes.JsonNode? that) { if (that == null) { @@ -1247,6 +1275,32 @@ public static Nodes.JsonObject ToJsonObject(Our.IClass that) failure.Error.Cause); } } + + /// + /// Serialize the JSON-able value into a JSON node. + /// + /// + /// The result is a deep, independent copy of . + /// A null is a JSON null. + /// + /// + /// Thrown when contains, at any depth, an infinity, + /// a not-a-number or an integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] + /// + public static Nodes.JsonNode? JsonValueToJsonNode(Nodes.JsonNode? that) + { + try + { + return Transformer.SerializeJsonValue(that); + } + catch (SerializationFailure failure) + { + throw new SerializationException( + Reporting.GenerateCSharpPath(failure.Error.PathSegments), + failure.Error.Cause); + } + } } // public static class Serialize } // public static class Jsonization } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/jsonvalueverification.cs b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/jsonvalueverification.cs index 6eb114d83..e8b97366c 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/jsonvalueverification.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/jsonvalueverification.cs @@ -15,6 +15,74 @@ namespace dummy /// public static class JsonValueVerification { + /// + /// Report that the integer of a JSON-able value is + /// not a safe integer. + /// + /// + /// See for what a safe + /// integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + private static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Check that , if it is a number at all, is + /// a number of a JSON-able value. + /// + /// + /// JSON knows a single numeric type, which all the other SDKs read into + /// a 64-bit floating-point number. 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. + /// + /// JSON value to be checked + /// + /// Cause why is not JSON-able, or null + /// if it is + /// + internal static string? CheckJsonAbleNumber(Nodes.JsonValue jsonValue) + { + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. + if (jsonValue.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + return UnsafeIntegerMessage(integer); + } + } + else if (jsonValue.TryGetValue(out ulong unsignedInteger)) + { + if (!Common.IsSafeInteger(unsignedInteger)) + { + return UnsafeIntegerMessage(unsignedInteger); + } + } + else if (jsonValue.TryGetValue(out double number)) + { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number. A conformant parser + // never gives us one, but the node may have been constructed + // programmatically. + if (!System.Double.IsFinite(number)) + { + return $"Expected a JSON-able value, but got the number {number}, " + + "which is neither finite nor representable in JSON"; + } + } + + return null; + } + /// /// Represent the expected top-level shape of a JSON-able value. /// @@ -38,14 +106,12 @@ public enum ExpectedShape } /// - /// Verify that holds no non-finite number anywhere, - /// at any depth. + /// Verify that holds no number anywhere, at any depth, + /// which is not a number of a JSON-able value. /// /// - /// JSON knows neither an infinity nor a not-a-number -- yet - /// holds either happily if it was put - /// together programmatically. A null is a JSON null, and hence - /// a JSON-able value. + /// See for what a JSON-able number is. + /// A null is a JSON null, and hence a JSON-able value. /// private static IEnumerable VerifyRecursively( Nodes.JsonNode that) @@ -91,17 +157,12 @@ public enum ExpectedShape break; case Nodes.JsonValue jsonValue: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though Nodes.JsonValue holds one happily - // if it was constructed programmatically. - if ( - jsonValue.TryGetValue(out double number) - && !System.Double.IsFinite(number)) { - yield return new Reporting.Error( - $"Expected a JSON-able value, but got the number {number}, " + - "which is neither finite nor representable in JSON"); + string? cause = CheckJsonAbleNumber(jsonValue); + if (cause != null) + { + yield return new Reporting.Error(cause); + } } break; @@ -113,8 +174,9 @@ public enum ExpectedShape /// /// Verify that is JSON-able (recursively - /// a null, a boolean, a finite number, a string, an array or an object of - /// JSON-able values -- never a non-finite number, at any depth) and, if + /// a null, a boolean, a number, a string, an array or an object of + /// JSON-able values -- never an infinity, a not-a-number or an integer outside + /// the safe integer range [-2^53 + 1, 2^53 - 1], at any depth) and, if /// is not /// , that its top-level shape matches. /// diff --git a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/xmlrpc.cs b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/xmlrpc.cs index 90d6e95e9..13101c5c3 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/xmlrpc.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_collections/expected_output/dummy/xmlrpc.cs @@ -721,6 +721,17 @@ public static void SerializeValueTo( break; case Nodes.JsonValue jsonValue: + // NOTE (mristin): + // We check a number exactly as the verification and the JSON + // de/serialization do. Otherwise, an integer outside the safe integer + // range would be quietly written as a different double below. + string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); + if (cause != null) + { + throw new SerializationFailure( + new Reporting.Error(cause)); + } + if (jsonValue.TryGetValue(out bool boolValue)) { writer.WriteStartElement("boolean", ""); @@ -733,22 +744,28 @@ public static void SerializeValueTo( writer.WriteString(boolValue ? "1" : "0"); writer.WriteEndElement(); } + else if (jsonValue.TryGetValue(out long integer)) + { + // NOTE (mristin): + // XML-RPC carries every JSON number in a , and the check + // above made sure that the integer is represented exactly. + writer.WriteStartElement("double", ""); + writer.WriteValue(integer); + writer.WriteEndElement(); + } + else if (jsonValue.TryGetValue(out ulong unsignedInteger)) + { + writer.WriteStartElement("double", ""); + writer.WriteValue((long)unsignedInteger); + writer.WriteEndElement(); + } else if (jsonValue.TryGetValue(out double doubleValue)) { // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither - // is a JSON-able value. XmlWriter would happily write "INF" or - // "NaN" -- the xs:double spellings -- which - // DeserializeDoubleTextFrom deliberately refuses to read back. - if (!System.Double.IsFinite(doubleValue)) - { - throw new SerializationFailure( - new Reporting.Error( - "Expected a JSON-able value, but got the number " + - $"{doubleValue}, which is neither finite nor " + - "representable in JSON")); - } - + // The check above refused an infinity and a not-a-number, which + // XmlWriter would otherwise happily write as "INF" or "NaN" -- the + // xs:double spellings -- and which DeserializeDoubleTextFrom + // deliberately refuses to read back. writer.WriteStartElement("double", ""); writer.WriteValue(doubleValue); writer.WriteEndElement(); diff --git a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..faf809abd --- /dev/null +++ b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,311 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + [Test] + public void TestSomethingMappingWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Mapping = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "mapping[\"key\"]"); + } + } // public void TestSomethingMappingWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingMappingWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Mapping = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertAccepted(instance); + } + } // public void TestSomethingMappingWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingValuesWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Values = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "values[0]"); + } + } // public void TestSomethingValuesWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingValuesWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Values = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertAccepted(instance); + } + } // public void TestSomethingValuesWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingOptionalMappingWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalMapping = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "optionalMapping[\"key\"]"); + } + } // public void TestSomethingOptionalMappingWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingOptionalMappingWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalMapping = new Nodes.JsonObject { ["key"] = Nodes.JsonValue.Create(value) }; + + AssertAccepted(instance); + } + } // public void TestSomethingOptionalMappingWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingOptionalValueWithAnIntegerOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalValue = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertRefused( + instance, + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "optionalValue[0]"); + } + } // public void TestSomethingOptionalValueWithAnIntegerOutsideTheSafeRange + + [Test] + public void TestSomethingOptionalValueWithIntegersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.OptionalValue = new Nodes.JsonArray(Nodes.JsonValue.Create(value)); + + AssertAccepted(instance); + } + } // public void TestSomethingOptionalValueWithIntegersAtTheBoundsOfTheSafeRange + + [Test] + public void Test_JsonValueFrom_checks_the_safe_integer_range() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var exception = Assert.Throws( + () => Our.Jsonization.Deserialize.JsonValueFrom( + new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); + Assert.AreEqual( + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception!.Cause); + Assert.AreEqual("[0]", exception!.Path); + } + + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + Assert.DoesNotThrow( + () => Our.Jsonization.Deserialize.JsonValueFrom( + new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); + } + } // public void Test_JsonValueFrom_checks_the_safe_integer_range + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy.Tests/TestXmlRpc.cs b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy.Tests/TestXmlRpc.cs index 3a028501a..993f20c05 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy.Tests/TestXmlRpc.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy.Tests/TestXmlRpc.cs @@ -379,6 +379,44 @@ public void Test_serialize_rejects_non_finite_number() } } + [Test] + public void Test_serialize_rejects_an_integer_outside_the_safe_range() + { + foreach (long value in new[] { 9007199254740992L, -9007199254740992L }) + { + var exception = Assert.Throws( + () => SerializeToString( + new Nodes.JsonArray(Nodes.JsonValue.Create(value)))); + Assert.AreEqual( + $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception!.Cause); + Assert.AreEqual("[0]", exception!.Path); + } + } + + [Test] + public void Test_round_trip_an_integer_at_the_bounds_of_the_safe_range() + { + foreach (long value in new[] { 9007199254740991L, -9007199254740991L }) + { + string text = SerializeToString(Nodes.JsonValue.Create(value)); + + Nodes.JsonNode? roundTripped = DeserializeFromString( + text, out Our.Reporting.Error? error); + + Assert.IsNull(error); + Assert.IsNotNull(roundTripped); + + // NOTE (mristin): + // XML-RPC carries every JSON number in a , which represents + // a safe integer exactly. + bool ok = roundTripped!.AsValue().TryGetValue(out double gotValue); + Assert.IsTrue(ok); + Assert.AreEqual((double)value, gotValue); + } + } + [Test] public void Test_deserialize_rejects_non_finite_number() { diff --git a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/common.cs index a0b3ba772..d50d971c9 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/common.cs @@ -24,6 +24,58 @@ public static int Len(System.Runtime.CompilerServices.ITuple tuple) return tuple.Length; } } // public static class TupleHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/jsonization.cs index d21e7e00a..2b713bc50 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -293,18 +263,10 @@ private static bool CheckIsJsonAble( return true; case Nodes.JsonValue jsonValue: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value. A conformant parser can never produce one, but - // the caller gives us a node which may have been constructed - // programmatically. - if ( - jsonValue.TryGetValue(out double number) - && !System.Double.IsFinite(number)) + string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); + if (cause != null) { - error = new Reporting.Error( - $"Expected a JSON-able value, but got the number {number}, " + - "which is neither finite nor representable in JSON"); + error = new Reporting.Error(cause); return false; } return true; @@ -565,6 +527,102 @@ public SerializationFailure(Reporting.Error error) /// public static class Deserialize { + /// + /// Deserialize a JSON-able value from . + /// + /// + /// A JSON-able value is, recursively, exactly as JSON itself is defined: + /// a null (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, independent copy of . + /// + /// JSON node to be parsed + /// + /// Thrown when is not a JSON-able + /// value. + /// + public static Nodes.JsonNode? JsonValueFrom( + Nodes.JsonNode? node) + { + Nodes.JsonNode? result = DeserializeImplementation.JsonValueFrom( + node, + out Reporting.Error? error); + if (error != null) + { + throw new Jsonization.Exception( + Reporting.GenerateJsonPath(error.PathSegments), + error.Cause); + } + return result; + } + + /// + /// Deserialize a JSON-able array from . + /// + /// + /// A JSON-able value is, recursively, exactly as JSON itself is defined: + /// a null (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, independent copy of . + /// + /// JSON node to be parsed + /// + /// Thrown when is not a JSON-able + /// array. + /// + public static Nodes.JsonArray JsonArrayFrom( + Nodes.JsonNode node) + { + Nodes.JsonArray result = DeserializeImplementation.JsonArrayFrom( + node, + out Reporting.Error? error); + if (error != null) + { + throw new Jsonization.Exception( + Reporting.GenerateJsonPath(error.PathSegments), + error.Cause); + } + return result; + } + + /// + /// Deserialize a JSON-able object from . + /// + /// + /// A JSON-able value is, recursively, exactly as JSON itself is defined: + /// a null (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, independent copy of . + /// + /// JSON node to be parsed + /// + /// Thrown when is not a JSON-able + /// object. + /// + public static Nodes.JsonObject JsonObjectFrom( + Nodes.JsonNode node) + { + Nodes.JsonObject result = DeserializeImplementation.JsonObjectFrom( + node, + out Reporting.Error? error); + if (error != null) + { + throw new Jsonization.Exception( + Reporting.GenerateJsonPath(error.PathSegments), + error.Cause); + } + return result; + } + /// /// Deserialize an instance of Something from . /// @@ -618,28 +676,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. @@ -737,18 +773,10 @@ private static void CheckIsJsonAbleForSerialization(Nodes.JsonNode that) break; case Nodes.JsonValue jsonValue: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though Nodes.JsonValue holds one happily - // if it was constructed programmatically. - if ( - jsonValue.TryGetValue(out double number) - && !System.Double.IsFinite(number)) + string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); + if (cause != null) { - throw new SerializationFailure( - new Reporting.Error( - $"Expected a JSON-able value, but got the number {number}, " + - "which is neither finite nor representable in JSON")); + throw new SerializationFailure(new Reporting.Error(cause)); } break; @@ -769,7 +797,7 @@ private static void CheckIsJsonAbleForSerialization(Nodes.JsonNode that) /// Thrown if contains a non-finite number anywhere, /// at any depth. /// - private static Nodes.JsonNode? SerializeJsonValue(Nodes.JsonNode? that) + internal static Nodes.JsonNode? SerializeJsonValue(Nodes.JsonNode? that) { if (that == null) { @@ -937,6 +965,32 @@ public static Nodes.JsonObject ToJsonObject(Our.IClass that) failure.Error.Cause); } } + + /// + /// Serialize the JSON-able value into a JSON node. + /// + /// + /// The result is a deep, independent copy of . + /// A null is a JSON null. + /// + /// + /// Thrown when contains, at any depth, an infinity, + /// a not-a-number or an integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] + /// + public static Nodes.JsonNode? JsonValueToJsonNode(Nodes.JsonNode? that) + { + try + { + return Transformer.SerializeJsonValue(that); + } + catch (SerializationFailure failure) + { + throw new SerializationException( + Reporting.GenerateCSharpPath(failure.Error.PathSegments), + failure.Error.Cause); + } + } } // public static class Serialize } // public static class Jsonization } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/jsonvalueverification.cs b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/jsonvalueverification.cs index 6eb114d83..e8b97366c 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/jsonvalueverification.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/jsonvalueverification.cs @@ -15,6 +15,74 @@ namespace dummy /// public static class JsonValueVerification { + /// + /// Report that the integer of a JSON-able value is + /// not a safe integer. + /// + /// + /// See for what a safe + /// integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + private static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected a JSON-able value, but got the integer {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Check that , if it is a number at all, is + /// a number of a JSON-able value. + /// + /// + /// JSON knows a single numeric type, which all the other SDKs read into + /// a 64-bit floating-point number. 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. + /// + /// JSON value to be checked + /// + /// Cause why is not JSON-able, or null + /// if it is + /// + internal static string? CheckJsonAbleNumber(Nodes.JsonValue jsonValue) + { + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. + if (jsonValue.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + return UnsafeIntegerMessage(integer); + } + } + else if (jsonValue.TryGetValue(out ulong unsignedInteger)) + { + if (!Common.IsSafeInteger(unsignedInteger)) + { + return UnsafeIntegerMessage(unsignedInteger); + } + } + else if (jsonValue.TryGetValue(out double number)) + { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number. A conformant parser + // never gives us one, but the node may have been constructed + // programmatically. + if (!System.Double.IsFinite(number)) + { + return $"Expected a JSON-able value, but got the number {number}, " + + "which is neither finite nor representable in JSON"; + } + } + + return null; + } + /// /// Represent the expected top-level shape of a JSON-able value. /// @@ -38,14 +106,12 @@ public enum ExpectedShape } /// - /// Verify that holds no non-finite number anywhere, - /// at any depth. + /// Verify that holds no number anywhere, at any depth, + /// which is not a number of a JSON-able value. /// /// - /// JSON knows neither an infinity nor a not-a-number -- yet - /// holds either happily if it was put - /// together programmatically. A null is a JSON null, and hence - /// a JSON-able value. + /// See for what a JSON-able number is. + /// A null is a JSON null, and hence a JSON-able value. /// private static IEnumerable VerifyRecursively( Nodes.JsonNode that) @@ -91,17 +157,12 @@ public enum ExpectedShape break; case Nodes.JsonValue jsonValue: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though Nodes.JsonValue holds one happily - // if it was constructed programmatically. - if ( - jsonValue.TryGetValue(out double number) - && !System.Double.IsFinite(number)) { - yield return new Reporting.Error( - $"Expected a JSON-able value, but got the number {number}, " + - "which is neither finite nor representable in JSON"); + string? cause = CheckJsonAbleNumber(jsonValue); + if (cause != null) + { + yield return new Reporting.Error(cause); + } } break; @@ -113,8 +174,9 @@ public enum ExpectedShape /// /// Verify that is JSON-able (recursively - /// a null, a boolean, a finite number, a string, an array or an object of - /// JSON-able values -- never a non-finite number, at any depth) and, if + /// a null, a boolean, a number, a string, an array or an object of + /// JSON-able values -- never an infinity, a not-a-number or an integer outside + /// the safe integer range [-2^53 + 1, 2^53 - 1], at any depth) and, if /// is not /// , that its top-level shape matches. /// diff --git a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/xmlrpc.cs b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/xmlrpc.cs index 90d6e95e9..13101c5c3 100644 --- a/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/xmlrpc.cs +++ b/dev/test_data/main/csharp/expected/json_types_in_invariants/expected_output/dummy/xmlrpc.cs @@ -721,6 +721,17 @@ public static void SerializeValueTo( break; case Nodes.JsonValue jsonValue: + // NOTE (mristin): + // We check a number exactly as the verification and the JSON + // de/serialization do. Otherwise, an integer outside the safe integer + // range would be quietly written as a different double below. + string? cause = JsonValueVerification.CheckJsonAbleNumber(jsonValue); + if (cause != null) + { + throw new SerializationFailure( + new Reporting.Error(cause)); + } + if (jsonValue.TryGetValue(out bool boolValue)) { writer.WriteStartElement("boolean", ""); @@ -733,22 +744,28 @@ public static void SerializeValueTo( writer.WriteString(boolValue ? "1" : "0"); writer.WriteEndElement(); } + else if (jsonValue.TryGetValue(out long integer)) + { + // NOTE (mristin): + // XML-RPC carries every JSON number in a , and the check + // above made sure that the integer is represented exactly. + writer.WriteStartElement("double", ""); + writer.WriteValue(integer); + writer.WriteEndElement(); + } + else if (jsonValue.TryGetValue(out ulong unsignedInteger)) + { + writer.WriteStartElement("double", ""); + writer.WriteValue((long)unsignedInteger); + writer.WriteEndElement(); + } else if (jsonValue.TryGetValue(out double doubleValue)) { // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither - // is a JSON-able value. XmlWriter would happily write "INF" or - // "NaN" -- the xs:double spellings -- which - // DeserializeDoubleTextFrom deliberately refuses to read back. - if (!System.Double.IsFinite(doubleValue)) - { - throw new SerializationFailure( - new Reporting.Error( - "Expected a JSON-able value, but got the number " + - $"{doubleValue}, which is neither finite nor " + - "representable in JSON")); - } - + // The check above refused an infinity and a not-a-number, which + // XmlWriter would otherwise happily write as "INF" or "NaN" -- the + // xs:double spellings -- and which DeserializeDoubleTextFrom + // deliberately refuses to read back. writer.WriteStartElement("double", ""); writer.WriteValue(doubleValue); writer.WriteEndElement(); diff --git a/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..daaf1eb2a --- /dev/null +++ b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestAnotherItemSerialNumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.AnotherItemFrom( + LoadTheFirstExpected("AnotherItem")); + + instance.SerialNumber = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "serialNumber"); + } + } // public void TestAnotherItemSerialNumberOutsideTheSafeRange + + [Test] + public void TestAnotherItemSerialNumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.AnotherItemFrom( + LoadTheFirstExpected("AnotherItem")); + + instance.SerialNumber = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.AnotherItemFrom, + Our.Xmlization.Deserialize.AnotherItemFrom); + } + } // public void TestAnotherItemSerialNumberAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/jsonization.cs index 5517e6bc6..cd3eede4d 100644 --- a/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -921,48 +972,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/verification.cs index dac0d3bcf..7ed0efc72 100644 --- a/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/verification.cs @@ -152,8 +152,12 @@ Our.ISomeItem that Our.IAnotherItem that ) { - // No verification has been defined for AnotherItem. - yield break; + foreach (var error in Verification.Verify_long(that.SerialNumber)) + { + error.PrependSegment( + new Reporting.NameSegment("serialNumber")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -255,6 +259,36 @@ var error in Verification.Verify_ListOf_ISimple( } } + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + /// /// Verify the items of recursively. /// diff --git a/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/xmlization.cs index 42fa34ed0..aaf9078c6 100644 --- a/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/list_of_classes/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -409,7 +444,8 @@ out Reporting.Error? error AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_ISimple = ( SimpleFromSequence); @@ -1304,7 +1340,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_ISimple = ( SimpleToSequence); diff --git a/dev/test_data/main/csharp/expected/list_of_constrained_primitives/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/list_of_constrained_primitives/expected_output/dummy/jsonization.cs index 74bd3172f..bcb4e56e5 100644 --- a/dev/test_data/main/csharp/expected/list_of_constrained_primitives/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/list_of_constrained_primitives/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -504,28 +474,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/list_of_enums/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/list_of_enums/expected_output/dummy/jsonization.cs index db8a2af74..bb068f2b6 100644 --- a/dev/test_data/main/csharp/expected/list_of_enums/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/list_of_enums/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -553,28 +523,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..1cf7b500c --- /dev/null +++ b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSomethingSomeIntsOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.SomeInts = new List() { 0L, value }; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "someInts[1]"); + } + } // public void TestSomethingSomeIntsOutsideTheSafeRange + + [Test] + public void TestSomethingSomeIntsAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.SomeInts = new List() { 0L, value }; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingSomeIntsAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/jsonization.cs index f476009ba..eaacaf932 100644 --- a/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -581,48 +632,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/verification.cs index ffdd03b77..3d2dcc8ec 100644 --- a/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/verification.cs @@ -310,6 +310,13 @@ Our.ISomething that "Invariant violated:\n" + "Some floats must be unique."); } + + foreach (var error in Verification.Verify_ListOf_long(that.SomeInts)) + { + error.PrependSegment( + new Reporting.NameSegment("someInts")); + yield return error; + } } } // private class Transformer @@ -326,6 +333,55 @@ Our.ISomething that yield return error; } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + + /// + /// Verify the items of recursively. + /// + private static IEnumerable Verify_ListOf_long( + List that) + { + int index = 0; + foreach (var item in that) + { + foreach (var error in Verification.Verify_long(item)) + { + error.PrependSegment( + new Reporting.IndexSegment(index)); + yield return error; + } + index++; + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/xmlization.cs index 3e4d2e24f..5de46662a 100644 --- a/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/list_of_primitives/expected_output/dummy/xmlization.cs @@ -256,6 +256,41 @@ private static byte[] ReadContentAsBytes(Xml.XmlReader reader) reader); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -446,7 +481,8 @@ out Reporting.Error? error Read_bool, "v"))); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader> Read_ListOf_long = ( AsList( @@ -924,7 +960,16 @@ internal static void WriteIClass( Write_bool, "v"))); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter> Write_ListOf_long = ( WriteList( diff --git a/dev/test_data/main/csharp/expected/method_references/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..fe6b5e0e5 --- /dev/null +++ b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestBoxSizeOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.BoxFrom( + LoadTheFirstExpected("Box")); + + instance.Size = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "size"); + } + } // public void TestBoxSizeOutsideTheSafeRange + + [Test] + public void TestBoxSizeAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.BoxFrom( + LoadTheFirstExpected("Box")); + + instance.Size = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.BoxFrom, + Our.Xmlization.Deserialize.BoxFrom); + } + } // public void TestBoxSizeAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/jsonization.cs index db9a7b39b..6c5105509 100644 --- a/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } + + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -547,48 +598,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/verification.cs index 4666c2881..47364c258 100644 --- a/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/verification.cs @@ -70,6 +70,13 @@ Our.IBox that "Invariant violated:\n" + "The size must be positive."); } + + foreach (var error in Verification.Verify_long(that.Size)) + { + error.PrependSegment( + new Reporting.NameSegment("size")); + yield return error; + } } } // private class Transformer @@ -86,6 +93,36 @@ Our.IBox that yield return error; } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/xmlization.cs index 4fa0d052d..e7e854679 100644 --- a/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/method_references/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -316,7 +351,8 @@ private static Reporting.Error DuplicatePropertyError(string elementName) AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); /// /// Deserialize an instance of IItem from an XML element. @@ -709,7 +745,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static void BoxToSequence( Our.IBox that, diff --git a/dev/test_data/main/csharp/expected/nested_any_in_all/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/nested_any_in_all/expected_output/dummy/jsonization.cs index 31420cbaf..10d073650 100644 --- a/dev/test_data/main/csharp/expected/nested_any_in_all/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/nested_any_in_all/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -1070,28 +1040,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..daaf1eb2a --- /dev/null +++ b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestAnotherItemSerialNumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.AnotherItemFrom( + LoadTheFirstExpected("AnotherItem")); + + instance.SerialNumber = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "serialNumber"); + } + } // public void TestAnotherItemSerialNumberOutsideTheSafeRange + + [Test] + public void TestAnotherItemSerialNumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.AnotherItemFrom( + LoadTheFirstExpected("AnotherItem")); + + instance.SerialNumber = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.AnotherItemFrom, + Our.Xmlization.Deserialize.AnotherItemFrom); + } + } // public void TestAnotherItemSerialNumberAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/common.cs index 392c9ee4e..1362641b5 100644 --- a/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/common.cs @@ -203,6 +203,58 @@ public static System.Collections.Generic.List Sorted( return result; } } // public static class SetHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/jsonization.cs index 08fbb5d61..eea063848 100644 --- a/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -1258,48 +1309,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/verification.cs index 8f0aac7ab..ad297afed 100644 --- a/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/verification.cs @@ -144,8 +144,12 @@ Our.ISomeItem that Our.IAnotherItem that ) { - // No verification has been defined for AnotherItem. - yield break; + foreach (var error in Verification.Verify_long(that.SerialNumber)) + { + error.PrependSegment( + new Reporting.NameSegment("serialNumber")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -227,6 +231,15 @@ Our.ISomething that "Grid must be rectangular."); } + foreach ( + var error in Verification.Verify_ListOf_ListOf_long( + that.Grid)) + { + error.PrependSegment( + new Reporting.NameSegment("grid")); + yield return error; + } + foreach ( var error in Verification.Verify_ListOf_ListOf_TupleOf2_PositiveInt_SetOf_string( that.RowsOfPairs)) @@ -311,6 +324,11 @@ var error in Verification.Verify_ListOf_ListOf_IAbstractItem( public static IEnumerable VerifyPositiveInt ( long that) { + foreach (var error in Verify_long(that)) + { + yield return error; + } + if (!(that > 0)) { yield return new Reporting.Error( @@ -341,6 +359,74 @@ var error in Verification.Verify_ListOf_ListOf_IAbstractItem( } } + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + + /// + /// Verify the items of recursively. + /// + private static IEnumerable Verify_ListOf_ListOf_long( + List> that) + { + int index = 0; + foreach (var item in that) + { + foreach (var error in Verification.Verify_ListOf_long(item)) + { + error.PrependSegment( + new Reporting.IndexSegment(index)); + yield return error; + } + index++; + } + } + + /// + /// Verify the items of recursively. + /// + private static IEnumerable Verify_ListOf_long( + List that) + { + int index = 0; + foreach (var item in that) + { + foreach (var error in Verification.Verify_long(item)) + { + error.PrependSegment( + new Reporting.IndexSegment(index)); + yield return error; + } + index++; + } + } + /// /// Verify the items of recursively. /// @@ -476,6 +562,13 @@ var item in Common.SetHelpers.Sorted( private static IEnumerable Verify_TupleOf2_long_Code( (long, string) that) { + foreach (var error in Verification.Verify_long(that.Item1)) + { + error.PrependSegment( + new Reporting.IndexSegment(0)); + yield return error; + } + foreach (var error in Verification.VerifyCode(that.Item2)) { error.PrependSegment( diff --git a/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/xmlization.cs index 74451c260..418cc23d4 100644 --- a/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/nested_collections/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -663,7 +698,8 @@ out Reporting.Error? error AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader> Read_ListOf_long = ( AsList( @@ -1843,7 +1879,16 @@ private static void WriteIUnion( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter> Write_ListOf_long = ( WriteList( diff --git a/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..d05e1ffd7 --- /dev/null +++ b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,160 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/jsonization.cs index 7aa756829..16905b971 100644 --- a/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -671,48 +722,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/verification.cs index 9796206c2..8d18aa8e8 100644 --- a/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/verification.cs @@ -220,6 +220,18 @@ Our.ISomething that yield return error; } + if (that.OptionalNumber.HasValue) + { + foreach ( + var error in Verification.Verify_long( + that.OptionalNumber.Value)) + { + error.PrependSegment( + new Reporting.NameSegment("optionalNumber")); + yield return error; + } + } + if (that.OptionalItem != null) { foreach (var error in Verification.Verify(that.OptionalItem)) @@ -271,6 +283,36 @@ var error in Verification.VerifyKind( $"Invalid Kind: {that}"); } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/xmlization.cs index f845cbb29..74c0743c2 100644 --- a/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/optional_arguments/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -445,7 +480,8 @@ out Reporting.Error? error ItemFromSequence); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader> Read_ListOf_string = ( AsList( @@ -1062,7 +1098,16 @@ internal static void WriteIClass( ItemToSequence); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter> Write_ListOf_string = ( WriteList( diff --git a/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..1d44d5e9c --- /dev/null +++ b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSomethingSomeIntOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.SomeInt = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "someInt"); + } + } // public void TestSomethingSomeIntOutsideTheSafeRange + + [Test] + public void TestSomethingSomeIntAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.SomeInt = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingSomeIntAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/common.cs index a0b3ba772..d50d971c9 100644 --- a/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/common.cs @@ -24,6 +24,58 @@ public static int Len(System.Runtime.CompilerServices.ITuple tuple) return tuple.Length; } } // public static class TupleHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/jsonization.cs index 1186d96a6..cd8201957 100644 --- a/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } + + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -477,48 +528,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/verification.cs index fc24e6d72..f107b4401 100644 --- a/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/verification.cs @@ -51,8 +51,12 @@ private class Transformer Our.ISomething that ) { - // No verification has been defined for Something. - yield break; + foreach (var error in Verification.Verify_long(that.SomeInt)) + { + error.PrependSegment( + new Reporting.NameSegment("someInt")); + yield return error; + } } } // private class Transformer @@ -69,6 +73,36 @@ Our.ISomething that yield return error; } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/xmlization.cs index 6f563739f..c9b4e0cf1 100644 --- a/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/primitive_types/expected_output/dummy/xmlization.cs @@ -256,6 +256,41 @@ private static byte[] ReadContentAsBytes(Xml.XmlReader reader) reader); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -369,7 +404,8 @@ private static Reporting.Error DuplicatePropertyError(string elementName) AsText(ReadContentAsBoolean)); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_double = ( AsText(ReadContentAsDouble)); @@ -759,7 +795,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_double = ( (that, writer) => writer.WriteValue(that)); diff --git a/dev/test_data/main/csharp/expected/problematic_keywords/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/problematic_keywords/expected_output/dummy/jsonization.cs index 9931ae5ed..64343aa1f 100644 --- a/dev/test_data/main/csharp/expected/problematic_keywords/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/problematic_keywords/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -461,28 +431,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/sets/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/sets/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..3975ceaa9 --- /dev/null +++ b/dev/test_data/main/csharp/expected/sets/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,286 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSomethingNumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "number"); + } + } // public void TestSomethingNumberOutsideTheSafeRange + + [Test] + public void TestSomethingNumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingNumberAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingNumbersOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Numbers = new List() { 0L, value }; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "numbers[1]"); + } + } // public void TestSomethingNumbersOutsideTheSafeRange + + [Test] + public void TestSomethingNumbersAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Numbers = new List() { 0L, value }; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingNumbersAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/sets/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/sets/expected_output/dummy/common.cs index d055b0926..155b80752 100644 --- a/dev/test_data/main/csharp/expected/sets/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/sets/expected_output/dummy/common.cs @@ -203,6 +203,58 @@ public static System.Collections.Generic.List Sorted( return result; } } // public static class SetHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/sets/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/sets/expected_output/dummy/jsonization.cs index 966e7406d..da73a87e1 100644 --- a/dev/test_data/main/csharp/expected/sets/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/sets/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -953,48 +1004,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/sets/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/sets/expected_output/dummy/verification.cs index 19c4ca546..bbed31fba 100644 --- a/dev/test_data/main/csharp/expected/sets/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/sets/expected_output/dummy/verification.cs @@ -507,6 +507,13 @@ Our.ISomething that "Texts must be unique."); } + foreach (var error in Verification.Verify_long(that.Number)) + { + error.PrependSegment( + new Reporting.NameSegment("number")); + yield return error; + } + foreach (var error in Verification.VerifyKind(that.Kind)) { error.PrependSegment( @@ -514,6 +521,13 @@ Our.ISomething that yield return error; } + foreach (var error in Verification.Verify_ListOf_long(that.Numbers)) + { + error.PrependSegment( + new Reporting.NameSegment("numbers")); + yield return error; + } + foreach (var error in Verification.Verify_ListOf_Kind(that.Kinds)) { error.PrependSegment( @@ -594,6 +608,13 @@ Our.ICollection that "Texts must not contain the forbidden text."); } + foreach (var error in Verification.Verify_SetOf_long(that.Numbers)) + { + error.PrependSegment( + new Reporting.NameSegment("numbers")); + yield return error; + } + foreach ( var error in Verification.Verify_SetOf_Direction( that.Directions)) @@ -680,6 +701,55 @@ var error in Verification.Verify_SetOf_Direction( } } + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + + /// + /// Verify the items of recursively. + /// + private static IEnumerable Verify_ListOf_long( + List that) + { + int index = 0; + foreach (var item in that) + { + foreach (var error in Verification.Verify_long(item)) + { + error.PrependSegment( + new Reporting.IndexSegment(index)); + yield return error; + } + index++; + } + } + /// /// Verify the items of recursively. /// @@ -718,6 +788,28 @@ var error in Verification.Verify_SetOf_Direction( } } + /// + /// Verify the items of recursively. + /// + private static IEnumerable Verify_SetOf_long( + HashSet that) + { + int index = 0; + foreach ( + var item in Common.SetHelpers.Sorted( + that, + System.Collections.Generic.Comparer.Default.Compare)) + { + foreach (var error in Verification.Verify_long(item)) + { + error.PrependSegment( + new Reporting.IndexSegment(index)); + yield return error; + } + index++; + } + } + /// /// Verify the items of recursively. /// diff --git a/dev/test_data/main/csharp/expected/sets/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/sets/expected_output/dummy/xmlization.cs index 941f4c59e..39b20192b 100644 --- a/dev/test_data/main/csharp/expected/sets/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/sets/expected_output/dummy/xmlization.cs @@ -212,6 +212,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -506,7 +541,8 @@ out Reporting.Error? error AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_Kind = ( AsEnum( @@ -1406,7 +1442,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_Kind = ( WriteEnum( diff --git a/dev/test_data/main/csharp/expected/string_operations/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/string_operations/expected_output/dummy/jsonization.cs index dd19ffba4..37151f75b 100644 --- a/dev/test_data/main/csharp/expected/string_operations/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/string_operations/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -476,28 +446,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..f45473dc8 --- /dev/null +++ b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSomethingNumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "number"); + } + } // public void TestSomethingNumberOutsideTheSafeRange + + [Test] + public void TestSomethingNumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Number = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingNumberAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/jsonization.cs index b0b9b5ea6..b2abf946e 100644 --- a/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } + + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -511,48 +562,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/verification.cs index 60fc0cea1..f8c8328ce 100644 --- a/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/verification.cs @@ -394,6 +394,13 @@ Our.ISomething that new Reporting.NameSegment("name")); yield return error; } + + foreach (var error in Verification.Verify_long(that.Number)) + { + error.PrependSegment( + new Reporting.NameSegment("number")); + yield return error; + } } } // private class Transformer @@ -438,6 +445,36 @@ Our.ISomething that "At least one character"); } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/xmlization.cs index 971d89bca..6bb5f14f2 100644 --- a/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/switch_statements/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -367,7 +402,8 @@ out Reporting.Error? error AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); /// /// Deserialize an instance of class Something from a sequence of XML elements. @@ -765,7 +801,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static void SomethingToSequence( Our.ISomething that, diff --git a/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..37913b5db --- /dev/null +++ b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,286 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestFirstCountOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.FirstFrom( + LoadTheFirstExpected("First")); + + instance.Count = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "count"); + } + } // public void TestFirstCountOutsideTheSafeRange + + [Test] + public void TestFirstCountAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.FirstFrom( + LoadTheFirstExpected("First")); + + instance.Count = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.FirstFrom, + Our.Xmlization.Deserialize.FirstFrom); + } + } // public void TestFirstCountAtTheBoundsOfTheSafeRange + + [Test] + public void TestSecondCountOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SecondFrom( + LoadTheFirstExpected("Second")); + + instance.Count = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "count"); + } + } // public void TestSecondCountOutsideTheSafeRange + + [Test] + public void TestSecondCountAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SecondFrom( + LoadTheFirstExpected("Second")); + + instance.Count = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SecondFrom, + Our.Xmlization.Deserialize.SecondFrom); + } + } // public void TestSecondCountAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/common.cs index 7f46d9811..9945bbb62 100644 --- a/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/common.cs @@ -187,6 +187,58 @@ public static long FloorMod(long dividend, long divisor) return remainder; } + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/jsonization.cs index f34f0da74..d2882c6ec 100644 --- a/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -833,48 +884,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/verification.cs index 8409a0826..7403af07b 100644 --- a/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/verification.cs @@ -100,6 +100,13 @@ Our.IFirst that "Parity is either zero or one."); } + foreach (var error in Verification.Verify_long(that.Count)) + { + error.PrependSegment( + new Reporting.NameSegment("count")); + yield return error; + } + if (that.Kind.HasValue) { foreach (var error in Verification.VerifyKind(that.Kind.Value)) @@ -151,6 +158,13 @@ Our.ISecond that "Prefix is at most three long."); } + foreach (var error in Verification.Verify_long(that.Count)) + { + error.PrependSegment( + new Reporting.NameSegment("count")); + yield return error; + } + if (that.Kind.HasValue) { foreach (var error in Verification.VerifyKind(that.Kind.Value)) @@ -190,6 +204,36 @@ Our.ISecond that $"Invalid Kind: {that}"); } } + + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } } // public static class Verification } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/xmlization.cs index d18ff7d8d..6077ce99a 100644 --- a/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/transpiled_methods/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -447,7 +482,8 @@ out Reporting.Error? error Read_string, "v"))); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader Read_Kind = ( AsEnum( @@ -1158,7 +1194,16 @@ internal static void WriteIClass( Write_string, "v"))); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter Write_Kind = ( WriteEnum( diff --git a/dev/test_data/main/csharp/expected/tuples/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..eb4b3c3eb --- /dev/null +++ b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,340 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestAnotherItemSerialNumberOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.AnotherItemFrom( + LoadTheFirstExpected("AnotherItem")); + + instance.SerialNumber = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "serialNumber"); + } + } // public void TestAnotherItemSerialNumberOutsideTheSafeRange + + [Test] + public void TestAnotherItemSerialNumberAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.AnotherItemFrom( + LoadTheFirstExpected("AnotherItem")); + + instance.SerialNumber = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.AnotherItemFrom, + Our.Xmlization.Deserialize.AnotherItemFrom); + } + } // public void TestAnotherItemSerialNumberAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingPairOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Pair = ( + instance.Pair.Item1, + value); + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "pair[1]"); + } + } // public void TestSomethingPairOutsideTheSafeRange + + [Test] + public void TestSomethingPairAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Pair = ( + instance.Pair.Item1, + value); + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingPairAtTheBoundsOfTheSafeRange + + [Test] + public void TestSomethingTrickyOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Tricky = ( + value, + instance.Tricky.Item2, + instance.Tricky.Item3, + instance.Tricky.Item4, + instance.Tricky.Item5, + instance.Tricky.Item6); + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "tricky[0]"); + } + } // public void TestSomethingTrickyOutsideTheSafeRange + + [Test] + public void TestSomethingTrickyAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SomethingFrom( + LoadTheFirstExpected("Something")); + + instance.Tricky = ( + value, + instance.Tricky.Item2, + instance.Tricky.Item3, + instance.Tricky.Item4, + instance.Tricky.Item5, + instance.Tricky.Item6); + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SomethingFrom, + Our.Xmlization.Deserialize.SomethingFrom); + } + } // public void TestSomethingTrickyAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/common.cs index a0b3ba772..d50d971c9 100644 --- a/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/common.cs @@ -24,6 +24,58 @@ public static int Len(System.Runtime.CompilerServices.ITuple tuple) return tuple.Length; } } // public static class TupleHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/jsonization.cs index 31f953f17..a1f10f2e4 100644 --- a/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -1024,48 +1075,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/verification.cs index 1b9986538..d0e6d8883 100644 --- a/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/verification.cs @@ -99,8 +99,12 @@ Our.ISomeItem that Our.IAnotherItem that ) { - // No verification has been defined for AnotherItem. - yield break; + foreach (var error in Verification.Verify_long(that.SerialNumber)) + { + error.PrependSegment( + new Reporting.NameSegment("serialNumber")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -131,6 +135,15 @@ Our.ISomething that "The optional pair must have two items"); } + foreach ( + var error in Verification.Verify_TupleOf2_string_long( + that.Pair)) + { + error.PrependSegment( + new Reporting.NameSegment("pair")); + yield return error; + } + foreach ( var error in Verification.Verify_TupleOf2_IAbstractItem_IAbstractItem( that.Items)) @@ -183,6 +196,11 @@ var error in Verification.Verify_TupleOf2_string_IAbstractItem( public static IEnumerable VerifyPositiveInt ( long that) { + foreach (var error in Verify_long(that)) + { + yield return error; + } + if (!(that > 0)) { yield return new Reporting.Error( @@ -205,6 +223,50 @@ var error in Verification.Verify_TupleOf2_string_IAbstractItem( } } + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + + /// + /// Verify the items of recursively. + /// + private static IEnumerable Verify_TupleOf2_string_long( + (string, long) that) + { + foreach (var error in Verification.Verify_long(that.Item2)) + { + error.PrependSegment( + new Reporting.IndexSegment(1)); + yield return error; + } + } + /// /// Verify the items of recursively. /// @@ -232,6 +294,13 @@ var error in Verification.Verify_TupleOf2_string_IAbstractItem( private static IEnumerable Verify_TupleOf6_long_ISomeItem_IAbstractItem_ISomeItem_PositiveInt_Result( (long, Our.ISomeItem, Our.IAbstractItem, Our.ISomeItem, long, Our.Result) that) { + foreach (var error in Verification.Verify_long(that.Item1)) + { + error.PrependSegment( + new Reporting.IndexSegment(0)); + yield return error; + } + foreach (var error in Verification.Verify(that.Item2)) { error.PrependSegment( diff --git a/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/xmlization.cs index fe63170c4..a45d66946 100644 --- a/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/tuples/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -593,7 +628,8 @@ out Reporting.Error? error AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader<(string, long)> Read_TupleOf2_string_long = ( AsTuple2( @@ -1534,7 +1570,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter<(string, long)> Write_TupleOf2_string_long = ( WriteTuple2( diff --git a/dev/test_data/main/csharp/expected/unions/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/unions/expected_output/dummy/jsonization.cs index 4dfbd549e..69a120841 100644 --- a/dev/test_data/main/csharp/expected/unions/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/unions/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -2096,28 +2066,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy.Tests/TestSafeIntegers.cs b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy.Tests/TestSafeIntegers.cs new file mode 100644 index 000000000..97ec80775 --- /dev/null +++ b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy.Tests/TestSafeIntegers.cs @@ -0,0 +1,248 @@ +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ + +using Our = dummy; // renamed + +using Directory = System.IO.Directory; +using Nodes = System.Text.Json.Nodes; +using Path = System.IO.Path; + +using System.Collections.Generic; // can't alias +using System.Linq; // can't alias +using NUnit.Framework; // can't alias + +namespace dummy.Tests +{ + public class TestSafeIntegers + { + [Test] + public void Test_IsSafeInteger() + { + foreach (var value in new long[] + { + 0L, 9007199254740991L, -9007199254740991L + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new long[] + { + 9007199254740992L, -9007199254740992L, long.MaxValue, long.MinValue + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + Assert.IsTrue(Our.Common.IsSafeInteger(9007199254740991UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(9007199254740992UL)); + Assert.IsFalse(Our.Common.IsSafeInteger(ulong.MaxValue)); + + foreach (var value in new double[] + { + 0.0, 9007199254740991.0, -9007199254740991.0 + }) + { + Assert.IsTrue(Our.Common.IsSafeInteger(value), $"for {value}"); + } + + foreach (var value in new double[] + { + 9007199254740992.0, + -9007199254740992.0, + double.PositiveInfinity, + double.NegativeInfinity, + double.NaN + }) + { + Assert.IsFalse(Our.Common.IsSafeInteger(value), $"for {value}"); + } + } // public void Test_IsSafeInteger + + /// + /// Read the first recorded example of the . + /// + private static Nodes.JsonNode LoadTheFirstExpected(string modelType) + { + var paths = Directory.GetFiles( + Path.Combine( + Our.Tests.Common.TestDataDir, + "Json", + "Expected", + modelType), + "*.json", + System.IO.SearchOption.AllDirectories).ToList(); + paths.Sort(); + + Assert.IsNotEmpty( + paths, + $"Expected at least one recorded example of {modelType}, but got none"); + + return Our.Tests.CommonJson.ReadFromFile(paths[0]); + } + + private static string SerializeToXml(Our.IClass instance) + { + var builder = new System.Text.StringBuilder(); + using (var writer = System.Xml.XmlWriter.Create( + builder, + new System.Xml.XmlWriterSettings() + { + Encoding = System.Text.Encoding.UTF8, + OmitXmlDeclaration = true + })) + { + Our.Xmlization.Serialize.To(instance, writer); + } + return builder.ToString(); + } + + /// + /// Assert that the verification, the JSON serialization and the XML + /// serialization all refuse with + /// . + /// + private static void AssertRefused( + Our.IClass instance, + string expectedCause, + string expectedJsonPath) + { + var errors = Our.Verification.Verify(instance).ToList(); + Assert.IsTrue( + errors.Any( + error => + error.Cause == expectedCause + && Reporting.GenerateJsonPath(error.PathSegments) == expectedJsonPath), + $"Expected the verification error {expectedCause} " + + $"at {expectedJsonPath}, but got: " + + string.Join( + "; ", + errors.Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}"))); + + var jsonException = Assert.Throws( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + var xmlException = Assert.Throws( + () => SerializeToXml(instance)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + /// + /// Assert that neither the verification, nor the JSON serialization nor + /// the XML serialization refuses an integer of + /// as outside the safe integer range. + /// + private static void AssertAccepted(Our.IClass instance) + { + var errors = Our.Verification.Verify(instance) + .Where(error => error.Cause.Contains("safe integer range")) + .Select( + error => $"{Reporting.GenerateJsonPath(error.PathSegments)}: {error.Cause}") + .ToList(); + Assert.IsEmpty(errors); + + Assert.DoesNotThrow( + () => Our.Jsonization.Serialize.ToJsonObject(instance)); + + Assert.DoesNotThrow( + () => SerializeToXml(instance)); + } + + private static Our.IClass DeserializeFromXml( + string text, + System.Func fromXml) + { + using var stringReader = new System.IO.StringReader(text); + using var xmlReader = System.Xml.XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return fromXml(xmlReader); + } + + /// + /// Assert that the de-serialization from JSON and from XML accepts + /// , which holds the bound + /// of the safe integer range, but refuses it + /// once the bound is moved by one out of the range. + /// + private static void AssertDeserializationChecksTheRange( + Our.IClass instance, + long safeValue, + System.Func fromJson, + System.Func fromXml) + { + long unsafeValue = safeValue > 0 ? safeValue + 1 : safeValue - 1; + string expectedCause = + $"Expected an integer, but got {unsafeValue}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + string jsonText = Our.Jsonization.Serialize.ToJsonObject(instance).ToJsonString(); + Assert.IsTrue(jsonText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => fromJson(Nodes.JsonNode.Parse(jsonText)!)); + + var jsonException = Assert.Throws( + () => fromJson( + Nodes.JsonNode.Parse( + jsonText.Replace("9007199254740991", "9007199254740992"))!)); + Assert.AreEqual(expectedCause, jsonException!.Cause); + + string xmlText = SerializeToXml(instance); + Assert.IsTrue(xmlText.Contains("9007199254740991")); + Assert.DoesNotThrow( + () => DeserializeFromXml(xmlText, fromXml)); + + var xmlException = Assert.Throws( + () => DeserializeFromXml( + xmlText.Replace("9007199254740991", "9007199254740992"), + fromXml)); + Assert.AreEqual(expectedCause, xmlException!.Cause); + } + + [Test] + public void TestSpecialItemLevelOutsideTheSafeRange() + { + foreach (var value in new long[] { 9007199254740992L, -9007199254740992L }) + { + var instance = Our.Jsonization.Deserialize.SpecialItemFrom( + LoadTheFirstExpected("SpecialItem")); + + instance.Level = value; + + AssertRefused( + instance, + $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + "level"); + } + } // public void TestSpecialItemLevelOutsideTheSafeRange + + [Test] + public void TestSpecialItemLevelAtTheBoundsOfTheSafeRange() + { + foreach (var value in new long[] { 9007199254740991L, -9007199254740991L }) + { + var instance = Our.Jsonization.Deserialize.SpecialItemFrom( + LoadTheFirstExpected("SpecialItem")); + + instance.Level = value; + + AssertAccepted(instance); + + AssertDeserializationChecksTheRange( + instance, + value, + Our.Jsonization.Deserialize.SpecialItemFrom, + Our.Xmlization.Deserialize.SpecialItemFrom); + } + } // public void TestSpecialItemLevelAtTheBoundsOfTheSafeRange + } // class TestSafeIntegers +} // namespace dummy.Tests + +/* + * This code has been automatically generated by aas-core-codegen. + * Do NOT edit or append. + */ diff --git a/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/common.cs b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/common.cs index dd0b6fa96..ec0d5f773 100644 --- a/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/common.cs +++ b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/common.cs @@ -84,6 +84,58 @@ public static int Len(string text) return CountCharacters(text, 0, text.Length); } } // public static class StringHelpers + + /// + /// Check whether is a safe integer, i.e., + /// whether it lies in the range [-2^53 + 1, 2^53 - 1]. + /// + /// + /// JSON knows a single numeric type, which the SDKs in other languages read + /// into a 64-bit floating-point number, so an integer outside this range might + /// be read as a different number. The verification, the de/serialization and + /// all use this check, so that an instance which + /// passes the verification can always be serialized. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(long value) + { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /// + /// Check whether is a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer to be checked + /// true if is a safe integer + public static bool IsSafeInteger(ulong value) + { + return value <= long.MaxValue && IsSafeInteger((long)value); + } + + /// + /// Check whether the whole number lies in the safe + /// integer range. + /// + /// + /// + /// See for what a safe integer is. + /// + /// + /// We check only the range, so the caller has to check first that + /// is a whole number. A not-a-number lies outside + /// the range. + /// + /// + /// whole number to be checked + /// true if is a safe integer + public static bool IsSafeInteger(double value) + { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } } // public static class Common } // namespace dummy diff --git a/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/jsonization.cs index e7640ed64..86de58ba0 100644 --- a/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/jsonization.cs @@ -95,6 +95,12 @@ internal static bool BoolFrom( /// /// Convert to a 64-bit long integer. /// + /// + /// A whole number written as a float, such as 5.0 or 1e2, is + /// accepted as well. An integer outside the safe integer range + /// [-2^53 + 1, 2^53 - 1] is refused, see + /// . + /// /// JSON node to be parsed /// Error, if any, during the deserialization internal static long LongFrom( @@ -103,23 +109,68 @@ internal static long LongFrom( { error = null; + // NOTE (mristin): + // We can not switch on the kind of the number, since + // ``Nodes.JsonValue.GetValueKind`` arrived only in .NET 8, and the generated + // code has to build against .NET 6. Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) + if (value != null) { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } + if (value.TryGetValue(out long integer)) + { + if (!Common.IsSafeInteger(integer)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(integer)); + return default; + } + return integer; + } + else if (value.TryGetValue(out int smallInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an int, which is + // always a safe integer. + return smallInteger; + } + else if (value.TryGetValue(out ulong unsignedInteger)) + { + // NOTE (mristin): + // Only a node constructed programmatically holds an ulong, as a parsed + // integer beyond the range of a long is read as a double below. + if (!Common.IsSafeInteger(unsignedInteger)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(unsignedInteger)); + return default; + } + return (long)unsignedInteger; + } + else if (value.TryGetValue(out double number)) + { + if (!System.Double.IsFinite(number) || System.Math.Truncate(number) != number) + { + error = new Reporting.Error( + $"Expected an integer, but got {number}, which is not a whole number"); + return default; + } - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; + // NOTE (mristin): + // We have to check the range before the cast, as the cast of a number + // outside the range of a long gives an unspecified value. + if (!Common.IsSafeInteger(number)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(number)); + return default; + } + return (long)number; + } } - return result; + + error = new Reporting.Error( + $"Expected an integer, but got: {Describe(node)}"); + return default; } /// @@ -1007,48 +1058,46 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) } /// - /// Convert 64-bit long integer to a JSON value. + /// Convert 64-bit floating-point number to a JSON + /// value. /// /// value to be converted /// - /// Thrown if lies outside the range where it can 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. + /// Thrown if is not finite. JSON knows neither + /// an infinity nor a not-a-number, so we refuse them here instead of + /// leaving it to System.Text.Json, which throws much later -- when + /// the caller writes the document out -- and says nothing about where + /// the offending value sat. /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) + private static Nodes.JsonValue ToJsonValue(double that) { - if (that < -9007199254740991L || that > 9007199254740991L) + if (!System.Double.IsFinite(that)) { throw new SerializationFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); + "JSON knows neither an infinity nor a not-a-number, so " + + $"the value can not be serialized: {that}")); } return Nodes.JsonValue.Create(that); } /// - /// Convert 64-bit floating-point number to a JSON - /// value. + /// Convert 64-bit long integer to a JSON value. /// /// value to be converted /// - /// Thrown if is not finite. JSON knows neither - /// an infinity nor a not-a-number, so we refuse them here instead of - /// leaving it to System.Text.Json, which throws much later -- when - /// the caller writes the document out -- and says nothing about where - /// the offending value sat. + /// Thrown if lies outside the safe integer range, + /// see . /// [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(double that) + private static Nodes.JsonValue ToJsonValue(long that) { - if (!System.Double.IsFinite(that)) + if (!Common.IsSafeInteger(that)) { throw new SerializationFailure( new Reporting.Error( - "JSON knows neither an infinity nor a not-a-number, so " + - $"the value can not be serialized: {that}")); + Verification.UnsafeIntegerMessage(that))); } return Nodes.JsonValue.Create(that); } diff --git a/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/verification.cs b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/verification.cs index ab19d73b6..e202b5a64 100644 --- a/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/verification.cs +++ b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/verification.cs @@ -448,8 +448,12 @@ Our.ISomeItem that Our.ISpecialItem that ) { - // No verification has been defined for SpecialItem. - yield break; + foreach (var error in Verification.Verify_long(that.Level)) + { + error.PrependSegment( + new Reporting.NameSegment("level")); + yield return error; + } } [CodeAnalysis.SuppressMessage("ReSharper", "NegativeEqualityExpression")] @@ -660,6 +664,36 @@ Our.IWrapper that } } + /// + /// Report that the integer is not a safe integer. + /// + /// + /// See for what a safe integer is. + /// + /// integer outside the safe integer range + /// the cause of the error + internal static string UnsafeIntegerMessage(System.IFormattable value) + { + return $"Expected an integer, but got {value}, " + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /// + /// Verify that lies in the safe integer range. + /// + /// + /// See for what a safe integer is. + /// + private static IEnumerable Verify_long( + long that) + { + if (!Common.IsSafeInteger(that)) + { + yield return new Reporting.Error( + UnsafeIntegerMessage(that)); + } + } + /// /// Verify the items of recursively. /// diff --git a/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/xmlization.cs b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/xmlization.cs index aad2d8e53..2c42de34c 100644 --- a/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/xmlization.cs +++ b/dev/test_data/main/csharp/expected/variance_of_containers/expected_output/dummy/xmlization.cs @@ -203,6 +203,41 @@ private static string ReadContentAsString(Xml.XmlReader reader) return reader.ReadContentAsString(); } + /// + /// Refuse an integer read by if it lies + /// outside the safe integer range. + /// + /// + /// The lexical rules of xs:long are left to , + /// while the range is checked exactly as in the JSON de-serialization, see + /// . + /// + private static ContentReader AsSafeInteger( + ContentReader readInteger + ) + { + return ( + Xml.XmlReader reader, + bool isEmpty, + out Reporting.Error? error + ) => + { + long result = readInteger(reader, isEmpty, out error); + if (error != null) + { + return default; + } + + if (!Common.IsSafeInteger(result)) + { + error = new Reporting.Error( + Verification.UnsafeIntegerMessage(result)); + return default; + } + return result; + }; + } + /// /// Bind to , /// so that the result reads the whole element, tags included. @@ -409,7 +444,8 @@ out Reporting.Error? error AsText(ReadContentAsString, "")); private static readonly ContentReader Read_long = ( - AsText(ReadContentAsLong)); + AsSafeInteger( + AsText(ReadContentAsLong))); private static readonly ContentReader> Read_ListOf_IItem = ( AsList( @@ -1419,7 +1455,16 @@ internal static void WriteIClass( (that, writer) => writer.WriteValue(that)); private static readonly ContentWriter Write_long = ( - (that, writer) => writer.WriteValue(that)); + (that, writer) => + { + if (!Common.IsSafeInteger(that)) + { + throw new SerializationFailure( + new Reporting.Error( + Verification.UnsafeIntegerMessage(that))); + } + writer.WriteValue(that); + }); private static readonly ContentWriter> Write_ListOf_IItem = ( WriteList( diff --git a/dev/test_data/main/csharp/expected/visibility/expected_output/dummy/jsonization.cs b/dev/test_data/main/csharp/expected/visibility/expected_output/dummy/jsonization.cs index e4394a957..707d38632 100644 --- a/dev/test_data/main/csharp/expected/visibility/expected_output/dummy/jsonization.cs +++ b/dev/test_data/main/csharp/expected/visibility/expected_output/dummy/jsonization.cs @@ -92,36 +92,6 @@ internal static bool BoolFrom( return result; } - /// - /// Convert to a 64-bit long integer. - /// - /// JSON node to be parsed - /// Error, if any, during the deserialization - internal static long LongFrom( - Nodes.JsonNode? node, - out Reporting.Error? error) - { - error = null; - - Nodes.JsonValue? value = node as Nodes.JsonValue; - if (value == null) - { - error = new Reporting.Error( - $"Expected a 64-bit long integer, but got {Describe(node)}"); - return default!; - } - - bool ok = value.TryGetValue(out long result); - if (!ok) - { - error = new Reporting.Error( - "Expected a 64-bit long integer, but the conversion failed " + - $"from {value.ToJsonString()}"); - return default!; - } - return result; - } - /// /// Convert to a 64-bit double-precision float. /// @@ -546,28 +516,6 @@ internal static Nodes.JsonObject TransformIClass(Our.IClass that) return _instance.Transform(that); } - /// - /// Convert 64-bit long integer to a JSON value. - /// - /// value to be converted - /// - /// Thrown if lies outside the range where it can 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. - /// - [CodeAnalysis.SuppressMessage("ReSharper", "UnusedMember.Local")] - private static Nodes.JsonValue ToJsonValue(long that) - { - if (that < -9007199254740991L || that > 9007199254740991L) - { - throw new SerializationFailure( - new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " + - $"the range [-2^53 + 1, 2^53 - 1]: {that}")); - } - return Nodes.JsonValue.Create(that); - } - /// /// Convert 64-bit floating-point number to a JSON /// value. diff --git a/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/internal/xmlcommon/xmlcommon.go index c0f4987e7..43b7e825e 100644 --- a/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/jsonization/jsonization.go index 7ac3d5939..b4d8a2a93 100644 --- a/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/jsonization/jsonization.go @@ -127,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -5929,30 +5869,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/xmlization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/xmlization/test/concrete_classes_test.go index 1e3b7dea3..186da4778 100644 --- a/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/xmlization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/aas_core_meta.v3/expected_output/xmlization/test/concrete_classes_test.go @@ -4862,13 +4862,13 @@ func TestDuplicatePropertyFails(t *testing.T) { } } -// Read the first recorded example of ILevelType with the content of -// the element `xmlName` replaced by `text`. -func readLevelTypeWith( +// Give the text of the first recorded example of ILevelType with +// the content of the element `xmlName` replaced by `text`. +func patchLevelTypeWith( t *testing.T, xmlName string, text string, -) ourtypes.ILevelType { +) string { pths := ourtesting.FindFilesBySuffixRecursively( filepath.Join( ourtesting.TestDataDir, @@ -4897,7 +4897,17 @@ func readLevelTypeWith( start := strings.Index(original, "<"+xmlName+">") + len(xmlName) + 2 end := strings.Index(original, "") - patched := original[:start] + text + original[end:] + return original[:start] + text + original[end:] +} + +// Read the first recorded example of ILevelType with the content of +// the element `xmlName` replaced by `text`. +func readLevelTypeWith( + t *testing.T, + xmlName string, + text string, +) ourtypes.ILevelType { + patched := patchLevelTypeWith(t, xmlName, text) decoder := xml.NewDecoder(strings.NewReader(patched)) deserialized, deseriaErr := ourxmlization.Unmarshal(decoder) diff --git a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/common/common.go b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/common/common.go index 201c0f3b1..517df2c63 100644 --- a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -283,5 +284,35 @@ func SetDifference[K comparable]( return result } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/jsonization/jsonization.go index af34ca4af..27803a546 100644 --- a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourstringification "github.com/dummy-works/dummy/stringification" ourtypes "github.com/dummy-works/dummy/types" @@ -127,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -399,6 +340,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Report that `jsonable` is no text of a literal of the enumeration `enumName`. func notAnEnumTextError( jsonable interface{}, @@ -1182,8 +1163,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -1192,17 +1174,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/jsonization/test/concrete_classes_test.go index cb38376eb..86c7737e2 100644 --- a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -491,7 +556,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestChildASerializationFailOnOutOfRangeAOnly(t *testing.T) { +func TestChildARefusesUnsafeIntegerInAOnly(t *testing.T) { pth := mustFirstExpectedPath(t, "ChildA") for _, value := range []int64{ @@ -509,15 +574,43 @@ func TestChildASerializationFailOnOutOfRangeAOnly(t *testing.T) { instance.SetAOnly(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "AOnly", "AOnly()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestChildBSerializationFailOnOutOfRangeBOnly(t *testing.T) { +func TestChildAAcceptsSafeIntegerInAOnly(t *testing.T) { + pth := mustFirstExpectedPath(t, "ChildA") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.ChildAFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetAOnly(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestChildBRefusesUnsafeIntegerInBOnly(t *testing.T) { pth := mustFirstExpectedPath(t, "ChildB") for _, value := range []int64{ @@ -535,15 +628,43 @@ func TestChildBSerializationFailOnOutOfRangeBOnly(t *testing.T) { instance.SetBOnly(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "BOnly", "BOnly()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { +func TestChildBAcceptsSafeIntegerInBOnly(t *testing.T) { + pth := mustFirstExpectedPath(t, "ChildB") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.ChildBFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetBOnly(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestSomethingRefusesUnsafeIntegerInNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -561,13 +682,41 @@ func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { instance.SetNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Number", "Number()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } +func TestSomethingAcceptsSafeIntegerInNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumber(value) + + assertSafeIntegerAccepted(t, instance) + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/verification/verification.go index 8e0dcd8cb..659485a19 100644 --- a/dev/test_data/main/golang/expected/annotated_assignments/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/annotated_assignments/expected_output/verification/verification.go @@ -371,6 +371,22 @@ func NoneIsShortAndTextIsShort( TextIsShort(ourcommon.NewAndPointTo(text)) } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.IItem]. // // You have to supply the callback `onError` to iterate over the errors. @@ -400,7 +416,20 @@ func VerifyChildA( ) (abort bool) { abort = false - // No verification has been defined for IChildA. + abort = verifyInt64( + that.AOnly(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "AOnly", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } @@ -417,7 +446,20 @@ func VerifyChildB( ) (abort bool) { abort = false - // No verification has been defined for IChildB. + abort = verifyInt64( + that.BOnly(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "BOnly", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } @@ -666,6 +708,21 @@ func VerifySomething( } } + abort = verifyInt64( + that.Number(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Number", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + abort = VerifyKind( that.Kind(), func(err *VerificationError) bool { diff --git a/dev/test_data/main/golang/expected/arithmetic/expected_output/common/common.go b/dev/test_data/main/golang/expected/arithmetic/expected_output/common/common.go index 9a7bd1716..3f59339af 100644 --- a/dev/test_data/main/golang/expected/arithmetic/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/arithmetic/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -268,5 +269,35 @@ func AbsInt64(x int64) int64 { return x } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/arithmetic/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/arithmetic/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/arithmetic/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/arithmetic/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/arithmetic/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/arithmetic/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/arithmetic/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/arithmetic/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/arithmetic/expected_output/jsonization/jsonization.go index 7333dd563..2b09a4c41 100644 --- a/dev/test_data/main/golang/expected/arithmetic/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/arithmetic/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.ISomething], // or return an error. func SomethingFromJsonable( @@ -673,8 +654,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -683,17 +665,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/arithmetic/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/arithmetic/expected_output/jsonization/test/concrete_classes_test.go index 53568c1f6..a865077ee 100644 --- a/dev/test_data/main/golang/expected/arithmetic/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/arithmetic/expected_output/jsonization/test/concrete_classes_test.go @@ -4,14 +4,18 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "math" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -72,6 +76,102 @@ 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( + t *testing.T, + that ourtypes.IClass, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, +) { + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) + + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { + t.Fatalf( + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, + ) + return + } + + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { + t.Fatalf( + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, + ) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { + t.Fatalf( + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, + ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return + } +} + func TestSomethingRoundTripOK(t *testing.T) { pths := ourtesting.FindFilesBySuffixRecursively( filepath.Join( @@ -177,7 +277,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeEven(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInEven(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -195,15 +295,43 @@ func TestSomethingSerializationFailOnOutOfRangeEven(t *testing.T) { instance.SetEven(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Even", "Even()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestSomethingSerializationFailOnOutOfRangeOffset(t *testing.T) { +func TestSomethingAcceptsSafeIntegerInEven(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetEven(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestSomethingRefusesUnsafeIntegerInOffset(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -221,15 +349,43 @@ func TestSomethingSerializationFailOnOutOfRangeOffset(t *testing.T) { instance.SetOffset(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Offset", "Offset()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestSomethingSerializationFailOnOutOfRangeSmall(t *testing.T) { +func TestSomethingAcceptsSafeIntegerInOffset(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetOffset(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestSomethingRefusesUnsafeIntegerInSmall(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -247,15 +403,43 @@ func TestSomethingSerializationFailOnOutOfRangeSmall(t *testing.T) { instance.SetSmall(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Small", "Small()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestSomethingSerializationFailOnOutOfRangeByNegative(t *testing.T) { +func TestSomethingAcceptsSafeIntegerInSmall(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetSmall(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestSomethingRefusesUnsafeIntegerInByNegative(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -273,15 +457,43 @@ func TestSomethingSerializationFailOnOutOfRangeByNegative(t *testing.T) { instance.SetByNegative(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "ByNegative", "ByNegative()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestSomethingSerializationFailOnOutOfRangeNegative(t *testing.T) { +func TestSomethingAcceptsSafeIntegerInByNegative(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetByNegative(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestSomethingRefusesUnsafeIntegerInNegative(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -299,15 +511,43 @@ func TestSomethingSerializationFailOnOutOfRangeNegative(t *testing.T) { instance.SetNegative(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Negative", "Negative()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestSomethingSerializationFailOnOutOfRangeDeviation(t *testing.T) { +func TestSomethingAcceptsSafeIntegerInNegative(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNegative(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestSomethingRefusesUnsafeIntegerInDeviation(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -325,14 +565,42 @@ func TestSomethingSerializationFailOnOutOfRangeDeviation(t *testing.T) { instance.SetDeviation(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Deviation", "Deviation()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } +func TestSomethingAcceptsSafeIntegerInDeviation(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetDeviation(value) + + assertSafeIntegerAccepted(t, instance) + } +} + func TestSomethingSerializationFailOnNonFiniteRatio(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") @@ -360,7 +628,7 @@ func TestSomethingSerializationFailOnNonFiniteRatio(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeAlignment(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInAlignment(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -378,13 +646,41 @@ func TestSomethingSerializationFailOnOutOfRangeAlignment(t *testing.T) { instance.SetAlignment(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Alignment", "Alignment()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } +func TestSomethingAcceptsSafeIntegerInAlignment(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetAlignment(value) + + assertSafeIntegerAccepted(t, instance) + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/arithmetic/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/arithmetic/expected_output/verification/verification.go index d2fe69889..ea72701de 100644 --- a/dev/test_data/main/golang/expected/arithmetic/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/arithmetic/expected_output/verification/verification.go @@ -94,6 +94,22 @@ func HasRemainderOneByLength( return ourcommon.FloorMod(number, int64(ourcommon.LenStr(text) + 1)) == 1 } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomething]. // // You have to supply the callback `onError` to iterate over the errors. @@ -257,6 +273,113 @@ func VerifySomething( return } + abort = verifyInt64( + that.Offset(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Offset", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + + abort = verifyInt64( + that.Small(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Small", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + + abort = verifyInt64( + that.ByNegative(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "ByNegative", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + + abort = verifyInt64( + that.Negative(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Negative", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + + abort = verifyInt64( + that.Deviation(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Deviation", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + + abort = verifyInt64( + that.Alignment(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Alignment", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + + if that.OptionalNumber() != nil { + abort = verifyInt64( + *that.OptionalNumber(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "OptionalNumber", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + return } @@ -272,6 +395,13 @@ func VerifyEvenInt( ) (abort bool) { abort = false + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + if !(ourcommon.FloorMod(that, 2) == 0) { abort = onError( newVerificationError( diff --git a/dev/test_data/main/golang/expected/assert_statements/expected_output/common/common.go b/dev/test_data/main/golang/expected/assert_statements/expected_output/common/common.go index 6dc34e60c..c2047c736 100644 --- a/dev/test_data/main/golang/expected/assert_statements/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/assert_statements/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -231,5 +232,35 @@ func LenStr(text string) int { return utf8.RuneCountInString(text) } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/assert_statements/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/assert_statements/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/assert_statements/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/assert_statements/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/assert_statements/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/assert_statements/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/assert_statements/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/assert_statements/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/assert_statements/expected_output/jsonization/jsonization.go index 5ddec405b..0794b4e3e 100644 --- a/dev/test_data/main/golang/expected/assert_statements/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/assert_statements/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.IParent], // or return an error. func ParentFromJsonable( @@ -805,8 +786,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -815,17 +797,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/assert_statements/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/assert_statements/expected_output/jsonization/test/concrete_classes_test.go index 4c8185b5e..f3ee712ec 100644 --- a/dev/test_data/main/golang/expected/assert_statements/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/assert_statements/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -386,7 +451,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestChildASerializationFailOnOutOfRangeNumber(t *testing.T) { +func TestChildARefusesUnsafeIntegerInNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "ChildA") for _, value := range []int64{ @@ -404,15 +469,43 @@ func TestChildASerializationFailOnOutOfRangeNumber(t *testing.T) { instance.SetNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Number", "Number()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestChildASerializationFailOnOutOfRangeAOnly(t *testing.T) { +func TestChildAAcceptsSafeIntegerInNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "ChildA") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.ChildAFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumber(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestChildARefusesUnsafeIntegerInAOnly(t *testing.T) { pth := mustFirstExpectedPath(t, "ChildA") for _, value := range []int64{ @@ -430,15 +523,43 @@ func TestChildASerializationFailOnOutOfRangeAOnly(t *testing.T) { instance.SetAOnly(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "AOnly", "AOnly()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestChildAAcceptsSafeIntegerInAOnly(t *testing.T) { + pth := mustFirstExpectedPath(t, "ChildA") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.ChildAFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetAOnly(value) + + assertSafeIntegerAccepted(t, instance) } } -func TestChildBSerializationFailOnOutOfRangeNumber(t *testing.T) { +func TestChildBRefusesUnsafeIntegerInNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "ChildB") for _, value := range []int64{ @@ -456,15 +577,43 @@ func TestChildBSerializationFailOnOutOfRangeNumber(t *testing.T) { instance.SetNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Number", "Number()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestChildBAcceptsSafeIntegerInNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "ChildB") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.ChildBFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumber(value) + + assertSafeIntegerAccepted(t, instance) } } -func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -482,11 +631,39 @@ func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { instance.SetNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Number", "Number()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumber(value) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/assert_statements/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/assert_statements/expected_output/verification/verification.go index a3c54c1ed..7ddf5a78f 100644 --- a/dev/test_data/main/golang/expected/assert_statements/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/assert_statements/expected_output/verification/verification.go @@ -130,6 +130,22 @@ func TextsAreConsistent( return true } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.IChildA]. // // You have to supply the callback `onError` to iterate over the errors. @@ -142,7 +158,35 @@ func VerifyChildA( ) (abort bool) { abort = false - // No verification has been defined for IChildA. + abort = verifyInt64( + that.Number(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Number", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + + abort = verifyInt64( + that.AOnly(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "AOnly", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } @@ -159,7 +203,20 @@ func VerifyChildB( ) (abort bool) { abort = false - // No verification has been defined for IChildB. + abort = verifyInt64( + that.Number(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Number", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } @@ -245,6 +302,21 @@ func VerifySomething( } } + abort = verifyInt64( + that.Number(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Number", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + if that.Texts() == nil { abort = onError( newVerificationError( diff --git a/dev/test_data/main/golang/expected/assert_statements/expected_output/xmlization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/assert_statements/expected_output/xmlization/test/concrete_classes_test.go index 71b775558..7d0346fab 100644 --- a/dev/test_data/main/golang/expected/assert_statements/expected_output/xmlization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/assert_statements/expected_output/xmlization/test/concrete_classes_test.go @@ -452,5 +452,131 @@ func TestDuplicatePropertyFails(t *testing.T) { } } +// Give the text of the first recorded example of IChildA with +// the content of the element `xmlName` replaced by `text`. +func patchChildAWith( + t *testing.T, + xmlName string, + text string, +) string { + pths := ourtesting.FindFilesBySuffixRecursively( + filepath.Join( + ourtesting.TestDataDir, + "Xml", + "Expected", + "childA", + ), + ".xml", + ) + sort.Strings(pths) + + if len(pths) == 0 { + t.Fatalf( + "Expected at least one recorded example of %s, but got none", + "childA", + ) + } + + bb, err := os.ReadFile(pths[0]) + if err != nil { + t.Fatalf("Failed to read the file %s: %s", pths[0], err.Error()) + } + + original := string(bb) + + start := strings.Index(original, "<"+xmlName+">") + len(xmlName) + 2 + end := strings.Index(original, "") + + return original[:start] + text + original[end:] +} + +// Read the first recorded example of IChildA with the content of +// the element `xmlName` replaced by `text`. +func readChildAWith( + t *testing.T, + xmlName string, + text string, +) ourtypes.IChildA { + patched := patchChildAWith(t, xmlName, text) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + deserialized, deseriaErr := ourxmlization.Unmarshal(decoder) + if deseriaErr != nil { + t.Fatalf( + "Expected no de-serialization error on %v, but got: %s", + patched, deseriaErr.Error(), + ) + } + + instance, ok := deserialized.(ourtypes.IChildA) + if !ok { + t.Fatalf("Expected an instance of IChildA, but got %T", deserialized) + } + + return instance +} + +func TestNumberReadFrom9007199254740991(t *testing.T) { + instance := readChildAWith( + t, + "number", + "9007199254740991", + ) + + got := instance.Number() + expected := int64(9007199254740991) + if got != expected { + t.Fatalf("Expected %v, but got %v", expected, got) + } +} + +func TestNumberReadFromMinus9007199254740991(t *testing.T) { + instance := readChildAWith( + t, + "number", + "-9007199254740991", + ) + + got := instance.Number() + expected := int64(-9007199254740991) + if got != expected { + t.Fatalf("Expected %v, but got %v", expected, got) + } +} + +func TestNumberReadFrom9007199254740992Fails(t *testing.T) { + patched := patchChildAWith( + t, + "number", + "9007199254740992", + ) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + _, err := ourxmlization.Unmarshal(decoder) + + expected := "Expected an integer, but got 9007199254740992, which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + deseriaErr, ok := err.(*ourxmlization.DeserializationError) + if !ok || deseriaErr.Message != expected { + t.Fatalf("Expected the de-serialization error %q, but got: %v", expected, err) + } +} + +func TestNumberReadFromMinus9007199254740992Fails(t *testing.T) { + patched := patchChildAWith( + t, + "number", + "-9007199254740992", + ) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + _, err := ourxmlization.Unmarshal(decoder) + + expected := "Expected an integer, but got -9007199254740992, which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + deseriaErr, ok := err.(*ourxmlization.DeserializationError) + if !ok || deseriaErr.Message != expected { + t.Fatalf("Expected the de-serialization error %q, but got: %v", expected, err) + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/assignment_targets/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/assignment_targets/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/assignment_targets/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/assignment_targets/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/assignment_targets/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/assignment_targets/expected_output/jsonization/jsonization.go index a9b9981bb..22e27be2d 100644 --- a/dev/test_data/main/golang/expected/assignment_targets/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/assignment_targets/expected_output/jsonization/jsonization.go @@ -127,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -707,30 +647,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/collections_in_returns/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/collections_in_returns/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/collections_in_returns/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/collections_in_returns/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/collections_in_returns/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/collections_in_returns/expected_output/jsonization/jsonization.go index 0b4fba705..9f31e1568 100644 --- a/dev/test_data/main/golang/expected/collections_in_returns/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/collections_in_returns/expected_output/jsonization/jsonization.go @@ -128,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -737,30 +677,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/comparisons/expected_output/common/common.go b/dev/test_data/main/golang/expected/comparisons/expected_output/common/common.go index 563c61769..6a3777de1 100644 --- a/dev/test_data/main/golang/expected/comparisons/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/comparisons/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -247,5 +248,35 @@ func CompareBools(that bool, other bool) int { return 1 } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/comparisons/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/comparisons/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/comparisons/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/comparisons/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/comparisons/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/comparisons/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/comparisons/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/comparisons/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/comparisons/expected_output/jsonization/jsonization.go index 4032a9a5d..c24290ddb 100644 --- a/dev/test_data/main/golang/expected/comparisons/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/comparisons/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.ISomething], // or return an error. func SomethingFromJsonable( @@ -603,8 +584,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -613,17 +595,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/comparisons/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/comparisons/expected_output/jsonization/test/concrete_classes_test.go index b46e71c37..314db97f5 100644 --- a/dev/test_data/main/golang/expected/comparisons/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/comparisons/expected_output/jsonization/test/concrete_classes_test.go @@ -4,14 +4,18 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "math" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -72,6 +76,102 @@ 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( + t *testing.T, + that ourtypes.IClass, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, +) { + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) + + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { + t.Fatalf( + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, + ) + return + } + + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { + t.Fatalf( + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, + ) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { + t.Fatalf( + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, + ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return + } +} + func TestSomethingRoundTripOK(t *testing.T) { pths := ourtesting.FindFilesBySuffixRecursively( filepath.Join( @@ -177,7 +277,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeCount(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInCount(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -195,14 +295,42 @@ func TestSomethingSerializationFailOnOutOfRangeCount(t *testing.T) { instance.SetCount(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Count", "Count()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } +func TestSomethingAcceptsSafeIntegerInCount(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetCount(value) + + assertSafeIntegerAccepted(t, instance) + } +} + func TestSomethingSerializationFailOnNonFiniteRatio(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") diff --git a/dev/test_data/main/golang/expected/comparisons/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/comparisons/expected_output/verification/verification.go index 979921856..d3d4cd9c7 100644 --- a/dev/test_data/main/golang/expected/comparisons/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/comparisons/expected_output/verification/verification.go @@ -88,6 +88,22 @@ func TextFitsIntoCount( (!(ourcommon.LenStr(text) > 100)) } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomething]. // // You have to supply the callback `onError` to iterate over the errors. @@ -154,6 +170,21 @@ func VerifySomething( } } + abort = verifyInt64( + that.Count(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Count", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + return } diff --git a/dev/test_data/main/golang/expected/comparisons/expected_output/xmlization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/comparisons/expected_output/xmlization/test/concrete_classes_test.go index 17ed40036..f603a98c2 100644 --- a/dev/test_data/main/golang/expected/comparisons/expected_output/xmlization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/comparisons/expected_output/xmlization/test/concrete_classes_test.go @@ -201,13 +201,13 @@ func TestDuplicatePropertyFails(t *testing.T) { } } -// Read the first recorded example of ISomething with the content of -// the element `xmlName` replaced by `text`. -func readSomethingWith( +// Give the text of the first recorded example of ISomething with +// the content of the element `xmlName` replaced by `text`. +func patchSomethingWith( t *testing.T, xmlName string, text string, -) ourtypes.ISomething { +) string { pths := ourtesting.FindFilesBySuffixRecursively( filepath.Join( ourtesting.TestDataDir, @@ -236,7 +236,17 @@ func readSomethingWith( start := strings.Index(original, "<"+xmlName+">") + len(xmlName) + 2 end := strings.Index(original, "") - patched := original[:start] + text + original[end:] + return original[:start] + text + original[end:] +} + +// Read the first recorded example of ISomething with the content of +// the element `xmlName` replaced by `text`. +func readSomethingWith( + t *testing.T, + xmlName string, + text string, +) ourtypes.ISomething { + patched := patchSomethingWith(t, xmlName, text) decoder := xml.NewDecoder(strings.NewReader(patched)) deserialized, deseriaErr := ourxmlization.Unmarshal(decoder) @@ -255,6 +265,68 @@ func readSomethingWith( return instance } +func TestCountReadFrom9007199254740991(t *testing.T) { + instance := readSomethingWith( + t, + "count", + "9007199254740991", + ) + + got := instance.Count() + expected := int64(9007199254740991) + if got != expected { + t.Fatalf("Expected %v, but got %v", expected, got) + } +} + +func TestCountReadFromMinus9007199254740991(t *testing.T) { + instance := readSomethingWith( + t, + "count", + "-9007199254740991", + ) + + got := instance.Count() + expected := int64(-9007199254740991) + if got != expected { + t.Fatalf("Expected %v, but got %v", expected, got) + } +} + +func TestCountReadFrom9007199254740992Fails(t *testing.T) { + patched := patchSomethingWith( + t, + "count", + "9007199254740992", + ) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + _, err := ourxmlization.Unmarshal(decoder) + + expected := "Expected an integer, but got 9007199254740992, which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + deseriaErr, ok := err.(*ourxmlization.DeserializationError) + if !ok || deseriaErr.Message != expected { + t.Fatalf("Expected the de-serialization error %q, but got: %v", expected, err) + } +} + +func TestCountReadFromMinus9007199254740992Fails(t *testing.T) { + patched := patchSomethingWith( + t, + "count", + "-9007199254740992", + ) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + _, err := ourxmlization.Unmarshal(decoder) + + expected := "Expected an integer, but got -9007199254740992, which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + deseriaErr, ok := err.(*ourxmlization.DeserializationError) + if !ok || deseriaErr.Message != expected { + t.Fatalf("Expected the de-serialization error %q, but got: %v", expected, err) + } +} + func TestRatioReadFrom1e400(t *testing.T) { instance := readSomethingWith( t, diff --git a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/common/common.go b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/common/common.go index 6dc34e60c..c2047c736 100644 --- a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -231,5 +232,35 @@ func LenStr(text string) int { return utf8.RuneCountInString(text) } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/jsonization/jsonization.go index 4d7dfdb9f..ab992b6ea 100644 --- a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.ISomething], // or return an error. func SomethingFromJsonable( @@ -603,8 +584,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -613,17 +595,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/jsonization/test/concrete_classes_test.go index 5120f8217..7bf592fa1 100644 --- a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/jsonization/test/concrete_classes_test.go @@ -4,14 +4,18 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "math" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -72,6 +76,102 @@ 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( + t *testing.T, + that ourtypes.IClass, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, +) { + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) + + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { + t.Fatalf( + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, + ) + return + } + + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { + t.Fatalf( + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, + ) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { + t.Fatalf( + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, + ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return + } +} + func TestSomethingRoundTripOK(t *testing.T) { pths := ourtesting.FindFilesBySuffixRecursively( filepath.Join( @@ -177,7 +277,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeSomeInt(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInSomeInt(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -195,14 +295,42 @@ func TestSomethingSerializationFailOnOutOfRangeSomeInt(t *testing.T) { instance.SetSomeInt(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "SomeInt", "SomeInt()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } +func TestSomethingAcceptsSafeIntegerInSomeInt(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetSomeInt(value) + + assertSafeIntegerAccepted(t, instance) + } +} + func TestSomethingSerializationFailOnNonFiniteSomeFloat(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") diff --git a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/verification/verification.go index cf87cf71d..a8b0abfba 100644 --- a/dev/test_data/main/golang/expected/constrained_primitives/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/constrained_primitives/expected_output/verification/verification.go @@ -45,6 +45,22 @@ func (ve *VerificationError) PathString() string { return ourreporting.ToGolangPath(ve.Path) } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomething]. // // You have to supply the callback `onError` to iterate over the errors. @@ -183,6 +199,13 @@ func VerifyPositiveInt( ) (abort bool) { abort = false + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + if !(that > 0) { abort = onError( newVerificationError( diff --git a/dev/test_data/main/golang/expected/custom_serialization_names/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/custom_serialization_names/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/custom_serialization_names/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/custom_serialization_names/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/custom_serialization_names/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/custom_serialization_names/expected_output/jsonization/jsonization.go index 8bf6feafd..b055a605c 100644 --- a/dev/test_data/main/golang/expected/custom_serialization_names/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/custom_serialization_names/expected_output/jsonization/jsonization.go @@ -126,66 +126,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -545,30 +485,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/common/common.go b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/common/common.go index 9cd0eae53..ffc9e4cb4 100644 --- a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" ) @@ -221,5 +222,35 @@ func (tuple Tuple8[T1, T2, T3, T4, T5, T6, T7, T8]) Len() int { return 8 } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/jsonization/jsonization.go index a7e7275f2..d8fbe3159 100644 --- a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.INode], // or return an error. func NodeFromJsonable( @@ -1162,8 +1143,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -1172,17 +1154,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/jsonization/test/concrete_classes_test.go index c0b3861aa..3e26eb32d 100644 --- a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -701,7 +766,7 @@ func TestContainerDeserializationFail(t *testing.T) { } } -func TestLeafSerializationFailOnOutOfRangeValue(t *testing.T) { +func TestLeafRefusesUnsafeIntegerInValue(t *testing.T) { pth := mustFirstExpectedPath(t, "Leaf") for _, value := range []int64{ @@ -719,15 +784,43 @@ func TestLeafSerializationFailOnOutOfRangeValue(t *testing.T) { instance.SetValue(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Value", "Value()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestLeafAcceptsSafeIntegerInValue(t *testing.T) { + pth := mustFirstExpectedPath(t, "Leaf") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.LeafFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetValue(value) + + assertSafeIntegerAccepted(t, instance) } } -func TestBlossomSerializationFailOnOutOfRangeValue(t *testing.T) { +func TestBlossomRefusesUnsafeIntegerInValue(t *testing.T) { pth := mustFirstExpectedPath(t, "Blossom") for _, value := range []int64{ @@ -745,11 +838,39 @@ func TestBlossomSerializationFailOnOutOfRangeValue(t *testing.T) { instance.SetValue(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Value", "Value()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestBlossomAcceptsSafeIntegerInValue(t *testing.T) { + pth := mustFirstExpectedPath(t, "Blossom") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.BlossomFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetValue(value) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/verification/verification.go index 3311341b0..ae292e51b 100644 --- a/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/deep_class_hierarchy/expected_output/verification/verification.go @@ -12,6 +12,7 @@ package verification import ( "fmt" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -44,6 +45,22 @@ func (ve *VerificationError) PathString() string { return ourreporting.ToGolangPath(ve.Path) } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.IBranch]. // // You have to supply the callback `onError` to iterate over the errors. @@ -73,7 +90,20 @@ func VerifyLeaf( ) (abort bool) { abort = false - // No verification has been defined for ILeaf. + abort = verifyInt64( + that.Value(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Value", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } @@ -90,7 +120,20 @@ func VerifyBlossom( ) (abort bool) { abort = false - // No verification has been defined for IBlossom. + abort = verifyInt64( + that.Value(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Value", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } diff --git a/dev/test_data/main/golang/expected/empty_class/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/empty_class/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/empty_class/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/empty_class/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/empty_class/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/empty_class/expected_output/jsonization/jsonization.go index 19678b154..9527cdf8c 100644 --- a/dev/test_data/main/golang/expected/empty_class/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/empty_class/expected_output/jsonization/jsonization.go @@ -126,66 +126,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -525,30 +465,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/enum/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/enum/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/enum/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/enum/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/enum/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/enum/expected_output/jsonization/jsonization.go index 7f669cc94..999dbba87 100644 --- a/dev/test_data/main/golang/expected/enum/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/enum/expected_output/jsonization/jsonization.go @@ -127,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -600,30 +540,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/float_only/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/float_only/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/float_only/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/float_only/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/float_only/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/float_only/expected_output/jsonization/jsonization.go index 726c3c17f..c64cb7b3c 100644 --- a/dev/test_data/main/golang/expected/float_only/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/float_only/expected_output/jsonization/jsonization.go @@ -127,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -633,30 +573,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/for_statements/expected_output/common/common.go b/dev/test_data/main/golang/expected/for_statements/expected_output/common/common.go index 621c7209d..dcc57c0bb 100644 --- a/dev/test_data/main/golang/expected/for_statements/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/for_statements/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -254,5 +255,35 @@ func FloorMod(dividend, divisor int64) int64 { return remainder } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/for_statements/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/for_statements/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/for_statements/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/for_statements/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/for_statements/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/for_statements/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/for_statements/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/for_statements/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/for_statements/expected_output/jsonization/jsonization.go index 84808a245..2c4eb92e7 100644 --- a/dev/test_data/main/golang/expected/for_statements/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/for_statements/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourstringification "github.com/dummy-works/dummy/stringification" ourtypes "github.com/dummy-works/dummy/types" @@ -127,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -399,6 +340,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Report that `jsonable` is no text of a literal of the enumeration `enumName`. func notAnEnumTextError( jsonable interface{}, @@ -707,8 +688,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -717,17 +699,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/for_statements/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/for_statements/expected_output/jsonization/test/concrete_classes_test.go index 745f1e072..45c9f2d8a 100644 --- a/dev/test_data/main/golang/expected/for_statements/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/for_statements/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -281,7 +346,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeNumbers(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInNumbers(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -299,11 +364,39 @@ func TestSomethingSerializationFailOnOutOfRangeNumbers(t *testing.T) { instance.SetNumbers([]int64{0, value}) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Numbers[1]", "Numbers()[1]", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInNumbers(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumbers([]int64{0, value}) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/for_statements/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/for_statements/expected_output/verification/verification.go index f436180c3..ac687c9d5 100644 --- a/dev/test_data/main/golang/expected/for_statements/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/for_statements/expected_output/verification/verification.go @@ -244,6 +244,22 @@ func TextsBeforeStopAreFew( return count < 3 } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.IItem]. // // You have to supply the callback `onError` to iterate over the errors. @@ -440,6 +456,21 @@ func VerifySomething( if abort { return } + } else { + abort = verifyListOf_long( + that.Numbers(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Numbers", + }, + ) + return onError(err) + }, + ) + if abort { + return + } } if that.Texts() == nil { @@ -482,6 +513,31 @@ func VerifySomething( return } +// Verify the items of `that` recursively. +func verifyListOf_long( + that []int64, + onError func(*VerificationError) bool, +) (abort bool) { + for i, item := range that { + abort = verifyInt64( + item, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: i, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + + return +} + // Verify the items of `that` recursively. func verifyListOf_IItem( that []ourtypes.IItem, diff --git a/dev/test_data/main/golang/expected/if_statements/expected_output/common/common.go b/dev/test_data/main/golang/expected/if_statements/expected_output/common/common.go index 621c7209d..dcc57c0bb 100644 --- a/dev/test_data/main/golang/expected/if_statements/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/if_statements/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -254,5 +255,35 @@ func FloorMod(dividend, divisor int64) int64 { return remainder } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/if_statements/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/if_statements/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/if_statements/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/if_statements/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/if_statements/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/if_statements/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/if_statements/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/if_statements/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/if_statements/expected_output/jsonization/jsonization.go index 520e6f573..ef4bb3152 100644 --- a/dev/test_data/main/golang/expected/if_statements/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/if_statements/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourstringification "github.com/dummy-works/dummy/stringification" ourtypes "github.com/dummy-works/dummy/types" @@ -127,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -399,6 +340,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Report that `jsonable` is no text of a literal of the enumeration `enumName`. func notAnEnumTextError( jsonable interface{}, @@ -1024,8 +1005,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -1034,17 +1016,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/if_statements/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/if_statements/expected_output/jsonization/test/concrete_classes_test.go index a34a405c2..1ba47090f 100644 --- a/dev/test_data/main/golang/expected/if_statements/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/if_statements/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -596,7 +661,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestChildASerializationFailOnOutOfRangeAOnly(t *testing.T) { +func TestChildARefusesUnsafeIntegerInAOnly(t *testing.T) { pth := mustFirstExpectedPath(t, "ChildA") for _, value := range []int64{ @@ -614,15 +679,43 @@ func TestChildASerializationFailOnOutOfRangeAOnly(t *testing.T) { instance.SetAOnly(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "AOnly", "AOnly()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestChildBSerializationFailOnOutOfRangeBOnly(t *testing.T) { +func TestChildAAcceptsSafeIntegerInAOnly(t *testing.T) { + pth := mustFirstExpectedPath(t, "ChildA") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.ChildAFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetAOnly(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestChildBRefusesUnsafeIntegerInBOnly(t *testing.T) { pth := mustFirstExpectedPath(t, "ChildB") for _, value := range []int64{ @@ -640,15 +733,43 @@ func TestChildBSerializationFailOnOutOfRangeBOnly(t *testing.T) { instance.SetBOnly(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "BOnly", "BOnly()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } -func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { +func TestChildBAcceptsSafeIntegerInBOnly(t *testing.T) { + pth := mustFirstExpectedPath(t, "ChildB") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.ChildBFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetBOnly(value) + + assertSafeIntegerAccepted(t, instance) + } +} + +func TestSomethingRefusesUnsafeIntegerInNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -666,13 +787,41 @@ func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { instance.SetNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Number", "Number()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } +func TestSomethingAcceptsSafeIntegerInNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumber(value) + + assertSafeIntegerAccepted(t, instance) + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/if_statements/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/if_statements/expected_output/verification/verification.go index d49772daa..3b13150bd 100644 --- a/dev/test_data/main/golang/expected/if_statements/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/if_statements/expected_output/verification/verification.go @@ -318,6 +318,22 @@ func HasMarkerInTree( return false } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.IItem]. // // You have to supply the callback `onError` to iterate over the errors. @@ -347,7 +363,20 @@ func VerifyChildA( ) (abort bool) { abort = false - // No verification has been defined for IChildA. + abort = verifyInt64( + that.AOnly(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "AOnly", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } @@ -364,7 +393,20 @@ func VerifyChildB( ) (abort bool) { abort = false - // No verification has been defined for IChildB. + abort = verifyInt64( + that.BOnly(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "BOnly", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } @@ -622,6 +664,21 @@ func VerifySomething( return } + abort = verifyInt64( + that.Number(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Number", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + if that.Item() == nil { abort = onError( newVerificationError( diff --git a/dev/test_data/main/golang/expected/implementation_specific/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/implementation_specific/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/implementation_specific/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/implementation_specific/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/implementation_specific/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/implementation_specific/expected_output/jsonization/jsonization.go index a15c96c31..a27fef269 100644 --- a/dev/test_data/main/golang/expected/implementation_specific/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/implementation_specific/expected_output/jsonization/jsonization.go @@ -127,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -830,30 +770,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/common/common.go b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/common/common.go index b0c4e7c3d..b79c48c12 100644 --- a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" ) @@ -244,5 +245,35 @@ func FloorMod(dividend, divisor int64) int64 { return remainder } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/jsonization/jsonization.go index c1cfc7838..6f0fda78f 100644 --- a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.ISomething], // or return an error. func SomethingFromJsonable( @@ -582,8 +563,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -592,17 +574,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/jsonization/test/concrete_classes_test.go index da3b2f7bb..c4339f797 100644 --- a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -176,7 +241,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeNumbers(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInNumbers(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -194,15 +259,43 @@ func TestSomethingSerializationFailOnOutOfRangeNumbers(t *testing.T) { instance.SetNumbers([]int64{0, value}) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Numbers[1]", "Numbers()[1]", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInNumbers(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumbers([]int64{0, value}) + + assertSafeIntegerAccepted(t, instance) } } -func TestSomethingSerializationFailOnOutOfRangeCount(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInCount(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -220,11 +313,39 @@ func TestSomethingSerializationFailOnOutOfRangeCount(t *testing.T) { instance.SetCount(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Count", "Count()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInCount(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetCount(value) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/verification/verification.go index ffe5523da..25fa8abab 100644 --- a/dev/test_data/main/golang/expected/int_length_interplay/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/int_length_interplay/expected_output/verification/verification.go @@ -83,10 +83,21 @@ func NoNumberIsSeven( } // 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. func NoSevenFrom( numbers []int64, start int64, ) bool { + if start < 0 { + return false + } + if start >= int64(len(numbers)) { + return true + } for i := start; i < int64(len(numbers)); i++ { number := numbers[i] if number == 7 { @@ -137,6 +148,22 @@ func SetCounts( return true } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomething]. // // You have to supply the callback `onError` to iterate over the errors. @@ -247,6 +274,21 @@ func VerifySomething( if abort { return } + } else { + abort = verifyListOf_long( + that.Numbers(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Numbers", + }, + ) + return onError(err) + }, + ) + if abort { + return + } } if that.Texts() == nil { @@ -260,6 +302,63 @@ func VerifySomething( } } + abort = verifyInt64( + that.Count(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Count", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + + if that.MaybeCount() != nil { + abort = verifyInt64( + *that.MaybeCount(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "MaybeCount", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + + return +} + +// Verify the items of `that` recursively. +func verifyListOf_long( + that []int64, + onError func(*VerificationError) bool, +) (abort bool) { + for i, item := range that { + abort = verifyInt64( + item, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: i, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + return } diff --git a/dev/test_data/main/golang/expected/int_parsing/expected_output/common/common.go b/dev/test_data/main/golang/expected/int_parsing/expected_output/common/common.go index cf6989f4d..c65d7e5f6 100644 --- a/dev/test_data/main/golang/expected/int_parsing/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/int_parsing/expected_output/common/common.go @@ -298,7 +298,12 @@ func SliceStrFrom(text string, start int64) string { // The meta-model calls `int` on strings, and this is its transpilation. We accept // only an optional sign followed by the ASCII digits, and only the safe integers, // i.e., the integers within -(2^53 - 1) and 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 an int64 could hold larger integers. +// TypeScript represents the integers as 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. // // Panics if text is not a safe integer. The meta-model is expected to check // the text before it calls `int`, so a panic signals a bug in the meta-model. @@ -333,8 +338,9 @@ func ParseSafeInt(text string) int64 { value = value*10 + int64(digit-'0') - // NOTE: This is 2^53 - 1. - if value > 9007199254740991 { + // NOTE: The value is never negative here, so only the upper bound + // matters. We check it on every digit so that the value never overflows. + if !IsSafeInteger(value) { panic( fmt.Sprintf( "Expected a safe integer, but got a text out of its range: %q", @@ -350,5 +356,15 @@ func ParseSafeInt(text string) int64 { return value } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/int_parsing/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/int_parsing/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..c85eaa5d3 --- /dev/null +++ b/dev/test_data/main/golang/expected/int_parsing/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,36 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/int_parsing/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/int_parsing/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/int_parsing/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/int_parsing/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/int_parsing/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/int_parsing/expected_output/jsonization/jsonization.go index a7d9524f3..00deda85a 100644 --- a/dev/test_data/main/golang/expected/int_parsing/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/int_parsing/expected_output/jsonization/jsonization.go @@ -126,66 +126,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -596,30 +536,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/is_instance/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/is_instance/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/is_instance/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/is_instance/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/is_instance/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/is_instance/expected_output/jsonization/jsonization.go index 159a47637..4b59a5380 100644 --- a/dev/test_data/main/golang/expected/is_instance/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/is_instance/expected_output/jsonization/jsonization.go @@ -126,66 +126,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -1518,30 +1458,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/json_types/expected_output/common/common.go b/dev/test_data/main/golang/expected/json_types/expected_output/common/common.go index 6dc34e60c..2cda335bb 100644 --- a/dev/test_data/main/golang/expected/json_types/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/json_types/expected_output/common/common.go @@ -5,6 +5,8 @@ package common // Do NOT edit or append. import ( + "fmt" + "math" "strings" "unicode/utf8" ) @@ -231,5 +233,42 @@ func LenStr(text string) int { return utf8.RuneCountInString(text) } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value` is not JSON-able, as it is not a safe +// integer, see [IsSafeInteger]. +func NotJsonAbleIntegerMessage(value int64) string { + return fmt.Sprintf( + "Expected a JSON-able value, but got the integer %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + +// Check that `value`, a number of a JSON-able value, is finite. +// +// JSON knows neither an infinity nor a not-a-number. +// +// Return the cause why `value` is not JSON-able, or an empty string if it is. +func CheckJsonAbleFloat64(value float64) string { + if math.IsInf(value, 0) || math.IsNaN(value) { + return fmt.Sprintf( + "Expected a JSON-able value, but got the number %v, "+ + "which is neither finite nor representable in JSON", + value, + ) + } + + return "" +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/json_types/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..c85eaa5d3 --- /dev/null +++ b/dev/test_data/main/golang/expected/json_types/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,36 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/json_types/expected_output/internal/xmlcommon/xmlcommon.go index af2fbf335..1d3728a8e 100644 --- a/dev/test_data/main/golang/expected/json_types/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/json_types/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -978,18 +944,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/jsonization.go index 19584f1f0..3c0295495 100644 --- a/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -439,29 +380,29 @@ func somethingFromMapWithoutDispatch( for k, v := range m { switch k { case "value": - theValue, err = jsonValueFromJsonable(v) + theValue, err = JsonValueFromJsonable(v) foundValue = true case "values": - theValues, err = jsonArrayFromJsonable(v) + theValues, err = JsonArrayFromJsonable(v) foundValues = true case "mapping": - theMapping, err = jsonObjectFromJsonable(v) + theMapping, err = JsonObjectFromJsonable(v) foundMapping = true case "mappingWithConstrainedKey": - theMappingWithConstrainedKey, err = jsonObjectFromJsonable(v) + theMappingWithConstrainedKey, err = JsonObjectFromJsonable(v) foundMappingWithConstrainedKey = true case "optionalValue": - theOptionalValue, err = jsonValueFromJsonable(v) + theOptionalValue, err = JsonValueFromJsonable(v) case "optionalValues": - theOptionalValues, err = jsonArrayFromJsonable(v) + theOptionalValues, err = JsonArrayFromJsonable(v) case "optionalMapping": - theOptionalMapping, err = jsonObjectFromJsonable(v) + theOptionalMapping, err = JsonObjectFromJsonable(v) default: err = newDeserializationError( @@ -610,30 +551,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to @@ -716,18 +633,28 @@ func serializeArray[T any]( // a not-a-number are rejected at any depth, as JSON can not represent them // at all. // +// An int or an int64, as it might come from a value put together +// programmatically, is accepted as well, and parsed as a float64, provided +// that it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// // The result is a new structure, and never `jsonable` itself, so that // the parsed instance does not alias the document it came from. // -// The path of an error is relative to `jsonable`, and the caller is expected -// to prepend the way to it. -func jsonValueFromJsonable( +// The path of an error is relative to `jsonable`. +func JsonValueFromJsonable( jsonable interface{}, ) (result ourtypes.JsonValue, err error) { if jsonable == nil { return } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both. + if integer, ok := jsonable.(int); ok { + jsonable = int64(integer) + } + switch casted := jsonable.(type) { case bool: result = casted @@ -737,19 +664,23 @@ func jsonValueFromJsonable( result = casted return + case int64: + if !ourcommon.IsSafeInteger(casted) { + err = newDeserializationError( + ourcommon.NotJsonAbleIntegerMessage(casted), + ) + return + } + result = float64(casted) + return + case float64: // NOTE (mristin): // JSON knows neither an infinity nor a not-a-number. encoding/json // never gives us one, but `jsonable` may well have been put together // programmatically. - if math.IsInf(casted, 0) || math.IsNaN(casted) { - err = newDeserializationError( - fmt.Sprintf( - "Expected a JSON-able value, but got the number %v, "+ - "which is neither finite nor representable in JSON", - casted, - ), - ) + if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" { + err = newDeserializationError(cause) return } result = casted @@ -759,7 +690,7 @@ func jsonValueFromJsonable( items := make(ourtypes.JsonArray, 0, len(casted)) for i, item := range casted { var parsed ourtypes.JsonValue - parsed, err = jsonValueFromJsonable(item) + parsed, err = JsonValueFromJsonable(item) if err != nil { mustDeserializationError(err).prependIndex(i) return @@ -773,7 +704,7 @@ func jsonValueFromJsonable( members := make(ourtypes.JsonObject, len(casted)) for key, value := range casted { var parsed ourtypes.JsonValue - parsed, err = jsonValueFromJsonable(value) + parsed, err = JsonValueFromJsonable(value) if err != nil { mustDeserializationError(err).prependKey(key) return @@ -786,8 +717,8 @@ func jsonValueFromJsonable( default: err = newDeserializationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "an array or an object), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, an array or an object), but got: %T", jsonable, ), ) @@ -796,7 +727,7 @@ func jsonValueFromJsonable( } // Parse `jsonable` as a JSON-able array. -func jsonArrayFromJsonable( +func JsonArrayFromJsonable( jsonable interface{}, ) (result ourtypes.JsonArray, err error) { if _, ok := jsonable.([]interface{}); !ok { @@ -807,7 +738,7 @@ func jsonArrayFromJsonable( } var value ourtypes.JsonValue - value, err = jsonValueFromJsonable(jsonable) + value, err = JsonValueFromJsonable(jsonable) if err != nil { return } @@ -817,7 +748,7 @@ func jsonArrayFromJsonable( } // Parse `jsonable` as a JSON-able object. -func jsonObjectFromJsonable( +func JsonObjectFromJsonable( jsonable interface{}, ) (result ourtypes.JsonObject, err error) { if _, ok := jsonable.(map[string]interface{}); !ok { @@ -828,7 +759,7 @@ func jsonObjectFromJsonable( } var value ourtypes.JsonValue - value, err = jsonValueFromJsonable(jsonable) + value, err = JsonValueFromJsonable(jsonable) if err != nil { return } @@ -844,15 +775,25 @@ func jsonObjectFromJsonable( // represent. It is refused here, at any depth. A nil is written as // a JSON null. // +// An int or an int64 is accepted as well, and written as a float64, provided +// that it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// // The result is a new structure, and never `value` itself, so that // the serialized document does not alias the instance it came from. -func jsonValueToJsonable( +func JsonValueToJsonable( value ourtypes.JsonValue, ) (result interface{}, err error) { if value == nil { return } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both. + if integer, ok := value.(int); ok { + value = int64(integer) + } + switch casted := value.(type) { case bool: result = casted @@ -862,17 +803,21 @@ func jsonValueToJsonable( result = casted return - case float64: - if math.IsInf(casted, 0) || math.IsNaN(casted) { + case int64: + if !ourcommon.IsSafeInteger(casted) { err = newSerializationError( - fmt.Sprintf( - "Expected a JSON-able value, but got the number %v, which "+ - "is neither finite nor representable in JSON", - casted, - ), + ourcommon.NotJsonAbleIntegerMessage(casted), ) return } + result = float64(casted) + return + + case float64: + if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" { + err = newSerializationError(cause) + return + } result = casted return @@ -880,7 +825,7 @@ func jsonValueToJsonable( items := make([]interface{}, 0, len(casted)) for i, item := range casted { var serialized interface{} - serialized, err = jsonValueToJsonable(item) + serialized, err = JsonValueToJsonable(item) if err != nil { mustSerializationError(err).prependIndex(i) return @@ -894,7 +839,7 @@ func jsonValueToJsonable( members := make(map[string]interface{}, len(casted)) for key, item := range casted { var serialized interface{} - serialized, err = jsonValueToJsonable(item) + serialized, err = JsonValueToJsonable(item) if err != nil { mustSerializationError(err).prependKey(key) return @@ -907,8 +852,8 @@ func jsonValueToJsonable( default: err = newSerializationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "a JsonArray or a JsonObject), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, a JsonArray or a JsonObject), but got: %T", value, ), ) @@ -927,25 +872,25 @@ func somethingToMap( ) (result map[string]interface{}, err error) { result = make(map[string]interface{}) - result["value"], err = jsonValueToJsonable(that.Value()) + result["value"], err = JsonValueToJsonable(that.Value()) if err != nil { mustSerializationError(err).prependName("Value()") return } - result["values"], err = jsonValueToJsonable(that.Values()) + result["values"], err = JsonValueToJsonable(that.Values()) if err != nil { mustSerializationError(err).prependName("Values()") return } - result["mapping"], err = jsonValueToJsonable(that.Mapping()) + result["mapping"], err = JsonValueToJsonable(that.Mapping()) if err != nil { mustSerializationError(err).prependName("Mapping()") return } - result["mappingWithConstrainedKey"], err = jsonValueToJsonable( + result["mappingWithConstrainedKey"], err = JsonValueToJsonable( that.MappingWithConstrainedKey(), ) if err != nil { @@ -954,7 +899,7 @@ func somethingToMap( } if that.OptionalValue() != nil { - result["optionalValue"], err = jsonValueToJsonable(that.OptionalValue()) + result["optionalValue"], err = JsonValueToJsonable(that.OptionalValue()) if err != nil { mustSerializationError(err).prependName("OptionalValue()") return @@ -962,7 +907,7 @@ func somethingToMap( } if that.OptionalValues() != nil { - result["optionalValues"], err = jsonValueToJsonable(that.OptionalValues()) + result["optionalValues"], err = JsonValueToJsonable(that.OptionalValues()) if err != nil { mustSerializationError(err).prependName("OptionalValues()") return @@ -970,7 +915,7 @@ func somethingToMap( } if that.OptionalMapping() != nil { - result["optionalMapping"], err = jsonValueToJsonable(that.OptionalMapping()) + result["optionalMapping"], err = JsonValueToJsonable(that.OptionalMapping()) if err != nil { mustSerializationError(err).prependName("OptionalMapping()") return diff --git a/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/test/concrete_classes_test.go index e41ea3710..1dc2502c9 100644 --- a/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/test/concrete_classes_test.go @@ -4,14 +4,138 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" + ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) +// Determine the path to the first recorded example of `modelType`. +func mustFirstExpectedPath(t *testing.T, modelType string) string { + pths := ourtesting.FindFilesBySuffixRecursively( + filepath.Join( + ourtesting.TestDataDir, + "Json", + "Expected", + modelType, + ), + ".json", + ) + sort.Strings(pths) + + if len(pths) == 0 { + t.Fatalf( + "Expected at least one recorded example of %s, but got none", + modelType, + ) + } + + return pths[0] +} + +// 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( + t *testing.T, + that ourtypes.IClass, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, +) { + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) + + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { + t.Fatalf( + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, + ) + return + } + + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { + t.Fatalf( + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, + ) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { + t.Fatalf( + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, + ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return + } +} + func TestSomethingRoundTripOK(t *testing.T) { pths := ourtesting.FindFilesBySuffixRecursively( filepath.Join( @@ -117,5 +241,59 @@ func TestSomethingDeserializationFail(t *testing.T) { } } +func TestSomethingRefusesUnsafeIntegerInValue(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetValue(ourtypes.JsonArray{value}) + + assertSafeIntegerRefused( + t, + instance, + "Value[0]", + "Value()[0]", + fmt.Sprintf( + "Expected a JSON-able value, but got the integer %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInValue(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetValue(ourtypes.JsonArray{value}) + + assertSafeIntegerAccepted(t, instance) + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/test/json_value_test.go b/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/test/json_value_test.go new file mode 100644 index 000000000..cdc603b41 --- /dev/null +++ b/dev/test_data/main/golang/expected/json_types/expected_output/jsonization/test/json_value_test.go @@ -0,0 +1,138 @@ +package jsonization_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "fmt" + "testing" + ourjsonization "github.com/dummy-works/dummy/jsonization" +) + +// Format the error of an integer outside the safe integer range. +func unsafeIntegerMessage(value int64) string { + return fmt.Sprintf( + "Expected a JSON-able value, but got the integer %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + +func TestJsonValueFromJsonableAcceptsSafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + result, err := ourjsonization.JsonValueFromJsonable(value) + if err != nil { + t.Fatalf( + "Expected no error when parsing %d, but got: %s", + value, err.Error(), + ) + return + } + + if result != float64(value) { + t.Fatalf( + "Expected %d to be parsed as a float64, but got %T: %v", + value, result, result, + ) + return + } + } +} + +func TestJsonValueFromJsonableRefusesUnsafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + } { + _, err := ourjsonization.JsonValueFromJsonable(value) + if err == nil { + t.Fatalf("Expected an error when parsing %d, but got none", value) + return + } + + deseriaErr, ok := err.(*ourjsonization.DeserializationError) + if !ok { + t.Fatalf( + "Expected a *DeserializationError, but got %T: %v", + err, err, + ) + return + } + + expected := unsafeIntegerMessage(value) + if deseriaErr.Message != expected { + t.Fatalf( + "Expected the error %q, but got %q", + expected, deseriaErr.Message, + ) + return + } + } +} + +func TestJsonValueToJsonableAcceptsSafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + result, err := ourjsonization.JsonValueToJsonable(value) + if err != nil { + t.Fatalf( + "Expected no error when serializing %d, but got: %s", + value, err.Error(), + ) + return + } + + if result != float64(value) { + t.Fatalf( + "Expected %d to be serialized as a float64, but got %T: %v", + value, result, result, + ) + return + } + } +} + +func TestJsonValueToJsonableRefusesUnsafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + } { + _, err := ourjsonization.JsonValueToJsonable( + []interface{}{ + map[string]interface{}{ + "key": value, + }, + }, + ) + if err == nil { + t.Fatalf("Expected an error when serializing %d, but got none", value) + return + } + + seriaErr, ok := err.(*ourjsonization.SerializationError) + if !ok { + t.Fatalf( + "Expected a *SerializationError, but got %T: %v", + err, err, + ) + return + } + + expected := unsafeIntegerMessage(value) + if seriaErr.Message != expected { + t.Fatalf( + "Expected the error %q, but got %q", + expected, seriaErr.Message, + ) + return + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/json_types/expected_output/verification/verification.go index 5ff392c22..23fbed761 100644 --- a/dev/test_data/main/golang/expected/json_types/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/json_types/expected_output/verification/verification.go @@ -12,7 +12,6 @@ package verification import ( "fmt" - "math" "sort" ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" @@ -70,6 +69,13 @@ func verifyJsonValue( return false } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both, as in the de/serialization. + if integer, ok := value.(int); ok { + value = int64(integer) + } + switch casted := value.(type) { case bool: return false @@ -77,23 +83,20 @@ func verifyJsonValue( case string: return false - case float64: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though a float64 holds either. - if math.IsInf(casted, 0) || math.IsNaN(casted) { + case int64: + if !ourcommon.IsSafeInteger(casted) { return onError( - newVerificationError( - fmt.Sprintf( - "Expected a JSON-able value, but got the number %v, "+ - "which is neither finite nor representable in JSON", - casted, - ), - ), + newVerificationError(ourcommon.NotJsonAbleIntegerMessage(casted)), ) } return false + case float64: + if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" { + return onError(newVerificationError(cause)) + } + return false + case ourtypes.JsonArray: for i, item := range casted { abort = verifyJsonValue( @@ -138,8 +141,8 @@ func verifyJsonValue( return onError( newVerificationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "a JsonArray or a JsonObject), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, a JsonArray or a JsonObject), but got: %T", value, ), ), diff --git a/dev/test_data/main/golang/expected/json_types/expected_output/xmlrpc/xmlrpc.go b/dev/test_data/main/golang/expected/json_types/expected_output/xmlrpc/xmlrpc.go index 9976acfba..d785b3b10 100644 --- a/dev/test_data/main/golang/expected/json_types/expected_output/xmlrpc/xmlrpc.go +++ b/dev/test_data/main/golang/expected/json_types/expected_output/xmlrpc/xmlrpc.go @@ -22,6 +22,7 @@ import ( "regexp" "sort" "strconv" + ourcommon "github.com/dummy-works/dummy/common" xmlcommon "github.com/dummy-works/dummy/internal/xmlcommon" ourtypes "github.com/dummy-works/dummy/types" ) @@ -499,6 +500,13 @@ func writeValueContent( return } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both, as in the JSON de/serialization and the verification. + if integer, ok := value.(int); ok { + value = int64(integer) + } + switch casted := value.(type) { case bool: // NOTE (mristin): @@ -518,6 +526,23 @@ func writeValueContent( ) return + case int64: + // NOTE (mristin): + // An integer is written as a ``, just as JSON knows a single + // numeric type, and hence has to lie in the safe integer range. + if !ourcommon.IsSafeInteger(casted) { + err = xmlcommon.NewSerializationError( + ourcommon.NotJsonAbleIntegerMessage(casted), + ) + return + } + err = xmlcommon.WriteElementInNoNamespace( + encoder, "double", undeclareNamespace, + float64(casted), + xmlcommon.WriteAsText_double, + ) + return + case float64: // NOTE (mristin): // JSON knows neither an infinity nor a not-a-number, so neither is @@ -567,8 +592,8 @@ func writeValueContent( default: err = xmlcommon.NewSerializationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "a JsonArray or a JsonObject), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, a JsonArray or a JsonObject), but got: %T", value, ), ) diff --git a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/common/common.go b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/common/common.go index 9cd0eae53..3e1d9b5df 100644 --- a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/common/common.go @@ -5,6 +5,8 @@ package common // Do NOT edit or append. import ( + "fmt" + "math" "strings" ) @@ -221,5 +223,42 @@ func (tuple Tuple8[T1, T2, T3, T4, T5, T6, T7, T8]) Len() int { return 8 } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value` is not JSON-able, as it is not a safe +// integer, see [IsSafeInteger]. +func NotJsonAbleIntegerMessage(value int64) string { + return fmt.Sprintf( + "Expected a JSON-able value, but got the integer %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + +// Check that `value`, a number of a JSON-able value, is finite. +// +// JSON knows neither an infinity nor a not-a-number. +// +// Return the cause why `value` is not JSON-able, or an empty string if it is. +func CheckJsonAbleFloat64(value float64) string { + if math.IsInf(value, 0) || math.IsNaN(value) { + return fmt.Sprintf( + "Expected a JSON-able value, but got the number %v, "+ + "which is neither finite nor representable in JSON", + value, + ) + } + + return "" +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..c85eaa5d3 --- /dev/null +++ b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,36 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/internal/xmlcommon/xmlcommon.go index af2fbf335..1d3728a8e 100644 --- a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -978,18 +944,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/jsonization/jsonization.go index e639eeca8..ac0592f71 100644 --- a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/jsonization/jsonization.go @@ -127,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -503,24 +443,24 @@ func somethingFromMapWithoutDispatch( for k, v := range m { switch k { case "values": - theValues, err = parseArray(v, jsonValueFromJsonable) + theValues, err = parseArray(v, JsonValueFromJsonable) foundValues = true case "tupleWithJson": theTupleWithJson, err = parseTuple4( v, stringFromJsonable, - jsonValueFromJsonable, - jsonArrayFromJsonable, - jsonObjectFromJsonable, + JsonValueFromJsonable, + JsonArrayFromJsonable, + JsonObjectFromJsonable, ) foundTupleWithJson = true case "optionalObjects": - theOptionalObjects, err = parseArray(v, jsonObjectFromJsonable) + theOptionalObjects, err = parseArray(v, JsonObjectFromJsonable) case "optionalObject": - theOptionalObject, err = jsonObjectFromJsonable(v) + theOptionalObject, err = JsonObjectFromJsonable(v) default: err = newDeserializationError( @@ -650,30 +590,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to @@ -755,13 +671,13 @@ func directToJsonable[T any](item T) (interface{}, error) { // Serialize the JSON-able `item` as a JSON-able value. // -// ``jsonValueToJsonable`` takes an ``ourtypes.JsonValue``, so a *function* +// ``JsonValueToJsonable`` takes an ``ourtypes.JsonValue``, so a *function* // which takes an ``ourtypes.JsonArray`` or an ``ourtypes.JsonObject`` is // a different type altogether, even though either value is assignable to // an ``ourtypes.JsonValue``. This wrapper gives the one signature which // ``serializeArray``/``serializeTupleN`` expect for every shape. func jsonAbleToJsonable[T any](item T) (interface{}, error) { - return jsonValueToJsonable(item) + return JsonValueToJsonable(item) } // Parse `jsonable` as a JSON-able value. @@ -772,18 +688,28 @@ func jsonAbleToJsonable[T any](item T) (interface{}, error) { // a not-a-number are rejected at any depth, as JSON can not represent them // at all. // +// An int or an int64, as it might come from a value put together +// programmatically, is accepted as well, and parsed as a float64, provided +// that it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// // The result is a new structure, and never `jsonable` itself, so that // the parsed instance does not alias the document it came from. // -// The path of an error is relative to `jsonable`, and the caller is expected -// to prepend the way to it. -func jsonValueFromJsonable( +// The path of an error is relative to `jsonable`. +func JsonValueFromJsonable( jsonable interface{}, ) (result ourtypes.JsonValue, err error) { if jsonable == nil { return } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both. + if integer, ok := jsonable.(int); ok { + jsonable = int64(integer) + } + switch casted := jsonable.(type) { case bool: result = casted @@ -793,19 +719,23 @@ func jsonValueFromJsonable( result = casted return + case int64: + if !ourcommon.IsSafeInteger(casted) { + err = newDeserializationError( + ourcommon.NotJsonAbleIntegerMessage(casted), + ) + return + } + result = float64(casted) + return + case float64: // NOTE (mristin): // JSON knows neither an infinity nor a not-a-number. encoding/json // never gives us one, but `jsonable` may well have been put together // programmatically. - if math.IsInf(casted, 0) || math.IsNaN(casted) { - err = newDeserializationError( - fmt.Sprintf( - "Expected a JSON-able value, but got the number %v, "+ - "which is neither finite nor representable in JSON", - casted, - ), - ) + if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" { + err = newDeserializationError(cause) return } result = casted @@ -815,7 +745,7 @@ func jsonValueFromJsonable( items := make(ourtypes.JsonArray, 0, len(casted)) for i, item := range casted { var parsed ourtypes.JsonValue - parsed, err = jsonValueFromJsonable(item) + parsed, err = JsonValueFromJsonable(item) if err != nil { mustDeserializationError(err).prependIndex(i) return @@ -829,7 +759,7 @@ func jsonValueFromJsonable( members := make(ourtypes.JsonObject, len(casted)) for key, value := range casted { var parsed ourtypes.JsonValue - parsed, err = jsonValueFromJsonable(value) + parsed, err = JsonValueFromJsonable(value) if err != nil { mustDeserializationError(err).prependKey(key) return @@ -842,8 +772,8 @@ func jsonValueFromJsonable( default: err = newDeserializationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "an array or an object), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, an array or an object), but got: %T", jsonable, ), ) @@ -852,7 +782,7 @@ func jsonValueFromJsonable( } // Parse `jsonable` as a JSON-able array. -func jsonArrayFromJsonable( +func JsonArrayFromJsonable( jsonable interface{}, ) (result ourtypes.JsonArray, err error) { if _, ok := jsonable.([]interface{}); !ok { @@ -863,7 +793,7 @@ func jsonArrayFromJsonable( } var value ourtypes.JsonValue - value, err = jsonValueFromJsonable(jsonable) + value, err = JsonValueFromJsonable(jsonable) if err != nil { return } @@ -873,7 +803,7 @@ func jsonArrayFromJsonable( } // Parse `jsonable` as a JSON-able object. -func jsonObjectFromJsonable( +func JsonObjectFromJsonable( jsonable interface{}, ) (result ourtypes.JsonObject, err error) { if _, ok := jsonable.(map[string]interface{}); !ok { @@ -884,7 +814,7 @@ func jsonObjectFromJsonable( } var value ourtypes.JsonValue - value, err = jsonValueFromJsonable(jsonable) + value, err = JsonValueFromJsonable(jsonable) if err != nil { return } @@ -900,15 +830,25 @@ func jsonObjectFromJsonable( // represent. It is refused here, at any depth. A nil is written as // a JSON null. // +// An int or an int64 is accepted as well, and written as a float64, provided +// that it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// // The result is a new structure, and never `value` itself, so that // the serialized document does not alias the instance it came from. -func jsonValueToJsonable( +func JsonValueToJsonable( value ourtypes.JsonValue, ) (result interface{}, err error) { if value == nil { return } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both. + if integer, ok := value.(int); ok { + value = int64(integer) + } + switch casted := value.(type) { case bool: result = casted @@ -918,17 +858,21 @@ func jsonValueToJsonable( result = casted return - case float64: - if math.IsInf(casted, 0) || math.IsNaN(casted) { + case int64: + if !ourcommon.IsSafeInteger(casted) { err = newSerializationError( - fmt.Sprintf( - "Expected a JSON-able value, but got the number %v, which "+ - "is neither finite nor representable in JSON", - casted, - ), + ourcommon.NotJsonAbleIntegerMessage(casted), ) return } + result = float64(casted) + return + + case float64: + if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" { + err = newSerializationError(cause) + return + } result = casted return @@ -936,7 +880,7 @@ func jsonValueToJsonable( items := make([]interface{}, 0, len(casted)) for i, item := range casted { var serialized interface{} - serialized, err = jsonValueToJsonable(item) + serialized, err = JsonValueToJsonable(item) if err != nil { mustSerializationError(err).prependIndex(i) return @@ -950,7 +894,7 @@ func jsonValueToJsonable( members := make(map[string]interface{}, len(casted)) for key, item := range casted { var serialized interface{} - serialized, err = jsonValueToJsonable(item) + serialized, err = JsonValueToJsonable(item) if err != nil { mustSerializationError(err).prependKey(key) return @@ -963,8 +907,8 @@ func jsonValueToJsonable( default: err = newSerializationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "a JsonArray or a JsonObject), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, a JsonArray or a JsonObject), but got: %T", value, ), ) @@ -1052,7 +996,7 @@ func somethingToMap( } if that.OptionalObject() != nil { - result["optionalObject"], err = jsonValueToJsonable(that.OptionalObject()) + result["optionalObject"], err = JsonValueToJsonable(that.OptionalObject()) if err != nil { mustSerializationError(err).prependName("OptionalObject()") return diff --git a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/jsonization/test/json_value_test.go b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/jsonization/test/json_value_test.go new file mode 100644 index 000000000..cdc603b41 --- /dev/null +++ b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/jsonization/test/json_value_test.go @@ -0,0 +1,138 @@ +package jsonization_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "fmt" + "testing" + ourjsonization "github.com/dummy-works/dummy/jsonization" +) + +// Format the error of an integer outside the safe integer range. +func unsafeIntegerMessage(value int64) string { + return fmt.Sprintf( + "Expected a JSON-able value, but got the integer %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + +func TestJsonValueFromJsonableAcceptsSafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + result, err := ourjsonization.JsonValueFromJsonable(value) + if err != nil { + t.Fatalf( + "Expected no error when parsing %d, but got: %s", + value, err.Error(), + ) + return + } + + if result != float64(value) { + t.Fatalf( + "Expected %d to be parsed as a float64, but got %T: %v", + value, result, result, + ) + return + } + } +} + +func TestJsonValueFromJsonableRefusesUnsafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + } { + _, err := ourjsonization.JsonValueFromJsonable(value) + if err == nil { + t.Fatalf("Expected an error when parsing %d, but got none", value) + return + } + + deseriaErr, ok := err.(*ourjsonization.DeserializationError) + if !ok { + t.Fatalf( + "Expected a *DeserializationError, but got %T: %v", + err, err, + ) + return + } + + expected := unsafeIntegerMessage(value) + if deseriaErr.Message != expected { + t.Fatalf( + "Expected the error %q, but got %q", + expected, deseriaErr.Message, + ) + return + } + } +} + +func TestJsonValueToJsonableAcceptsSafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + result, err := ourjsonization.JsonValueToJsonable(value) + if err != nil { + t.Fatalf( + "Expected no error when serializing %d, but got: %s", + value, err.Error(), + ) + return + } + + if result != float64(value) { + t.Fatalf( + "Expected %d to be serialized as a float64, but got %T: %v", + value, result, result, + ) + return + } + } +} + +func TestJsonValueToJsonableRefusesUnsafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + } { + _, err := ourjsonization.JsonValueToJsonable( + []interface{}{ + map[string]interface{}{ + "key": value, + }, + }, + ) + if err == nil { + t.Fatalf("Expected an error when serializing %d, but got none", value) + return + } + + seriaErr, ok := err.(*ourjsonization.SerializationError) + if !ok { + t.Fatalf( + "Expected a *SerializationError, but got %T: %v", + err, err, + ) + return + } + + expected := unsafeIntegerMessage(value) + if seriaErr.Message != expected { + t.Fatalf( + "Expected the error %q, but got %q", + expected, seriaErr.Message, + ) + return + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/verification/verification.go index 41d676584..02307ea1a 100644 --- a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/verification/verification.go @@ -12,7 +12,6 @@ package verification import ( "fmt" - "math" "sort" ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" @@ -70,6 +69,13 @@ func verifyJsonValue( return false } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both, as in the de/serialization. + if integer, ok := value.(int); ok { + value = int64(integer) + } + switch casted := value.(type) { case bool: return false @@ -77,23 +83,20 @@ func verifyJsonValue( case string: return false - case float64: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though a float64 holds either. - if math.IsInf(casted, 0) || math.IsNaN(casted) { + case int64: + if !ourcommon.IsSafeInteger(casted) { return onError( - newVerificationError( - fmt.Sprintf( - "Expected a JSON-able value, but got the number %v, "+ - "which is neither finite nor representable in JSON", - casted, - ), - ), + newVerificationError(ourcommon.NotJsonAbleIntegerMessage(casted)), ) } return false + case float64: + if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" { + return onError(newVerificationError(cause)) + } + return false + case ourtypes.JsonArray: for i, item := range casted { abort = verifyJsonValue( @@ -138,8 +141,8 @@ func verifyJsonValue( return onError( newVerificationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "a JsonArray or a JsonObject), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, a JsonArray or a JsonObject), but got: %T", value, ), ), diff --git a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/xmlrpc/xmlrpc.go b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/xmlrpc/xmlrpc.go index 9976acfba..d785b3b10 100644 --- a/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/xmlrpc/xmlrpc.go +++ b/dev/test_data/main/golang/expected/json_types_in_collections/expected_output/xmlrpc/xmlrpc.go @@ -22,6 +22,7 @@ import ( "regexp" "sort" "strconv" + ourcommon "github.com/dummy-works/dummy/common" xmlcommon "github.com/dummy-works/dummy/internal/xmlcommon" ourtypes "github.com/dummy-works/dummy/types" ) @@ -499,6 +500,13 @@ func writeValueContent( return } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both, as in the JSON de/serialization and the verification. + if integer, ok := value.(int); ok { + value = int64(integer) + } + switch casted := value.(type) { case bool: // NOTE (mristin): @@ -518,6 +526,23 @@ func writeValueContent( ) return + case int64: + // NOTE (mristin): + // An integer is written as a ``, just as JSON knows a single + // numeric type, and hence has to lie in the safe integer range. + if !ourcommon.IsSafeInteger(casted) { + err = xmlcommon.NewSerializationError( + ourcommon.NotJsonAbleIntegerMessage(casted), + ) + return + } + err = xmlcommon.WriteElementInNoNamespace( + encoder, "double", undeclareNamespace, + float64(casted), + xmlcommon.WriteAsText_double, + ) + return + case float64: // NOTE (mristin): // JSON knows neither an infinity nor a not-a-number, so neither is @@ -567,8 +592,8 @@ func writeValueContent( default: err = xmlcommon.NewSerializationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "a JsonArray or a JsonObject), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, a JsonArray or a JsonObject), but got: %T", value, ), ) diff --git a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/common/common.go b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/common/common.go index 9cd0eae53..3e1d9b5df 100644 --- a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/common/common.go @@ -5,6 +5,8 @@ package common // Do NOT edit or append. import ( + "fmt" + "math" "strings" ) @@ -221,5 +223,42 @@ func (tuple Tuple8[T1, T2, T3, T4, T5, T6, T7, T8]) Len() int { return 8 } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value` is not JSON-able, as it is not a safe +// integer, see [IsSafeInteger]. +func NotJsonAbleIntegerMessage(value int64) string { + return fmt.Sprintf( + "Expected a JSON-able value, but got the integer %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + +// Check that `value`, a number of a JSON-able value, is finite. +// +// JSON knows neither an infinity nor a not-a-number. +// +// Return the cause why `value` is not JSON-able, or an empty string if it is. +func CheckJsonAbleFloat64(value float64) string { + if math.IsInf(value, 0) || math.IsNaN(value) { + return fmt.Sprintf( + "Expected a JSON-able value, but got the number %v, "+ + "which is neither finite nor representable in JSON", + value, + ) + } + + return "" +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..c85eaa5d3 --- /dev/null +++ b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,36 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/internal/xmlcommon/xmlcommon.go index af2fbf335..1d3728a8e 100644 --- a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -978,18 +944,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/jsonization/jsonization.go index f1ec67d28..89f133204 100644 --- a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -434,18 +375,18 @@ func somethingFromMapWithoutDispatch( for k, v := range m { switch k { case "mapping": - theMapping, err = jsonObjectFromJsonable(v) + theMapping, err = JsonObjectFromJsonable(v) foundMapping = true case "values": - theValues, err = jsonArrayFromJsonable(v) + theValues, err = JsonArrayFromJsonable(v) foundValues = true case "optionalMapping": - theOptionalMapping, err = jsonObjectFromJsonable(v) + theOptionalMapping, err = JsonObjectFromJsonable(v) case "optionalValue": - theOptionalValue, err = jsonValueFromJsonable(v) + theOptionalValue, err = JsonValueFromJsonable(v) default: err = newDeserializationError( @@ -575,30 +516,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to @@ -681,18 +598,28 @@ func serializeArray[T any]( // a not-a-number are rejected at any depth, as JSON can not represent them // at all. // +// An int or an int64, as it might come from a value put together +// programmatically, is accepted as well, and parsed as a float64, provided +// that it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// // The result is a new structure, and never `jsonable` itself, so that // the parsed instance does not alias the document it came from. // -// The path of an error is relative to `jsonable`, and the caller is expected -// to prepend the way to it. -func jsonValueFromJsonable( +// The path of an error is relative to `jsonable`. +func JsonValueFromJsonable( jsonable interface{}, ) (result ourtypes.JsonValue, err error) { if jsonable == nil { return } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both. + if integer, ok := jsonable.(int); ok { + jsonable = int64(integer) + } + switch casted := jsonable.(type) { case bool: result = casted @@ -702,19 +629,23 @@ func jsonValueFromJsonable( result = casted return + case int64: + if !ourcommon.IsSafeInteger(casted) { + err = newDeserializationError( + ourcommon.NotJsonAbleIntegerMessage(casted), + ) + return + } + result = float64(casted) + return + case float64: // NOTE (mristin): // JSON knows neither an infinity nor a not-a-number. encoding/json // never gives us one, but `jsonable` may well have been put together // programmatically. - if math.IsInf(casted, 0) || math.IsNaN(casted) { - err = newDeserializationError( - fmt.Sprintf( - "Expected a JSON-able value, but got the number %v, "+ - "which is neither finite nor representable in JSON", - casted, - ), - ) + if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" { + err = newDeserializationError(cause) return } result = casted @@ -724,7 +655,7 @@ func jsonValueFromJsonable( items := make(ourtypes.JsonArray, 0, len(casted)) for i, item := range casted { var parsed ourtypes.JsonValue - parsed, err = jsonValueFromJsonable(item) + parsed, err = JsonValueFromJsonable(item) if err != nil { mustDeserializationError(err).prependIndex(i) return @@ -738,7 +669,7 @@ func jsonValueFromJsonable( members := make(ourtypes.JsonObject, len(casted)) for key, value := range casted { var parsed ourtypes.JsonValue - parsed, err = jsonValueFromJsonable(value) + parsed, err = JsonValueFromJsonable(value) if err != nil { mustDeserializationError(err).prependKey(key) return @@ -751,8 +682,8 @@ func jsonValueFromJsonable( default: err = newDeserializationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "an array or an object), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, an array or an object), but got: %T", jsonable, ), ) @@ -761,7 +692,7 @@ func jsonValueFromJsonable( } // Parse `jsonable` as a JSON-able array. -func jsonArrayFromJsonable( +func JsonArrayFromJsonable( jsonable interface{}, ) (result ourtypes.JsonArray, err error) { if _, ok := jsonable.([]interface{}); !ok { @@ -772,7 +703,7 @@ func jsonArrayFromJsonable( } var value ourtypes.JsonValue - value, err = jsonValueFromJsonable(jsonable) + value, err = JsonValueFromJsonable(jsonable) if err != nil { return } @@ -782,7 +713,7 @@ func jsonArrayFromJsonable( } // Parse `jsonable` as a JSON-able object. -func jsonObjectFromJsonable( +func JsonObjectFromJsonable( jsonable interface{}, ) (result ourtypes.JsonObject, err error) { if _, ok := jsonable.(map[string]interface{}); !ok { @@ -793,7 +724,7 @@ func jsonObjectFromJsonable( } var value ourtypes.JsonValue - value, err = jsonValueFromJsonable(jsonable) + value, err = JsonValueFromJsonable(jsonable) if err != nil { return } @@ -809,15 +740,25 @@ func jsonObjectFromJsonable( // represent. It is refused here, at any depth. A nil is written as // a JSON null. // +// An int or an int64 is accepted as well, and written as a float64, provided +// that it lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// // The result is a new structure, and never `value` itself, so that // the serialized document does not alias the instance it came from. -func jsonValueToJsonable( +func JsonValueToJsonable( value ourtypes.JsonValue, ) (result interface{}, err error) { if value == nil { return } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both. + if integer, ok := value.(int); ok { + value = int64(integer) + } + switch casted := value.(type) { case bool: result = casted @@ -827,17 +768,21 @@ func jsonValueToJsonable( result = casted return - case float64: - if math.IsInf(casted, 0) || math.IsNaN(casted) { + case int64: + if !ourcommon.IsSafeInteger(casted) { err = newSerializationError( - fmt.Sprintf( - "Expected a JSON-able value, but got the number %v, which "+ - "is neither finite nor representable in JSON", - casted, - ), + ourcommon.NotJsonAbleIntegerMessage(casted), ) return } + result = float64(casted) + return + + case float64: + if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" { + err = newSerializationError(cause) + return + } result = casted return @@ -845,7 +790,7 @@ func jsonValueToJsonable( items := make([]interface{}, 0, len(casted)) for i, item := range casted { var serialized interface{} - serialized, err = jsonValueToJsonable(item) + serialized, err = JsonValueToJsonable(item) if err != nil { mustSerializationError(err).prependIndex(i) return @@ -859,7 +804,7 @@ func jsonValueToJsonable( members := make(map[string]interface{}, len(casted)) for key, item := range casted { var serialized interface{} - serialized, err = jsonValueToJsonable(item) + serialized, err = JsonValueToJsonable(item) if err != nil { mustSerializationError(err).prependKey(key) return @@ -872,8 +817,8 @@ func jsonValueToJsonable( default: err = newSerializationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "a JsonArray or a JsonObject), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, a JsonArray or a JsonObject), but got: %T", value, ), ) @@ -892,20 +837,20 @@ func somethingToMap( ) (result map[string]interface{}, err error) { result = make(map[string]interface{}) - result["mapping"], err = jsonValueToJsonable(that.Mapping()) + result["mapping"], err = JsonValueToJsonable(that.Mapping()) if err != nil { mustSerializationError(err).prependName("Mapping()") return } - result["values"], err = jsonValueToJsonable(that.Values()) + result["values"], err = JsonValueToJsonable(that.Values()) if err != nil { mustSerializationError(err).prependName("Values()") return } if that.OptionalMapping() != nil { - result["optionalMapping"], err = jsonValueToJsonable(that.OptionalMapping()) + result["optionalMapping"], err = JsonValueToJsonable(that.OptionalMapping()) if err != nil { mustSerializationError(err).prependName("OptionalMapping()") return @@ -913,7 +858,7 @@ func somethingToMap( } if that.OptionalValue() != nil { - result["optionalValue"], err = jsonValueToJsonable(that.OptionalValue()) + result["optionalValue"], err = JsonValueToJsonable(that.OptionalValue()) if err != nil { mustSerializationError(err).prependName("OptionalValue()") return diff --git a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/jsonization/test/json_value_test.go b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/jsonization/test/json_value_test.go new file mode 100644 index 000000000..cdc603b41 --- /dev/null +++ b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/jsonization/test/json_value_test.go @@ -0,0 +1,138 @@ +package jsonization_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "fmt" + "testing" + ourjsonization "github.com/dummy-works/dummy/jsonization" +) + +// Format the error of an integer outside the safe integer range. +func unsafeIntegerMessage(value int64) string { + return fmt.Sprintf( + "Expected a JSON-able value, but got the integer %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + +func TestJsonValueFromJsonableAcceptsSafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + result, err := ourjsonization.JsonValueFromJsonable(value) + if err != nil { + t.Fatalf( + "Expected no error when parsing %d, but got: %s", + value, err.Error(), + ) + return + } + + if result != float64(value) { + t.Fatalf( + "Expected %d to be parsed as a float64, but got %T: %v", + value, result, result, + ) + return + } + } +} + +func TestJsonValueFromJsonableRefusesUnsafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + } { + _, err := ourjsonization.JsonValueFromJsonable(value) + if err == nil { + t.Fatalf("Expected an error when parsing %d, but got none", value) + return + } + + deseriaErr, ok := err.(*ourjsonization.DeserializationError) + if !ok { + t.Fatalf( + "Expected a *DeserializationError, but got %T: %v", + err, err, + ) + return + } + + expected := unsafeIntegerMessage(value) + if deseriaErr.Message != expected { + t.Fatalf( + "Expected the error %q, but got %q", + expected, deseriaErr.Message, + ) + return + } + } +} + +func TestJsonValueToJsonableAcceptsSafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + result, err := ourjsonization.JsonValueToJsonable(value) + if err != nil { + t.Fatalf( + "Expected no error when serializing %d, but got: %s", + value, err.Error(), + ) + return + } + + if result != float64(value) { + t.Fatalf( + "Expected %d to be serialized as a float64, but got %T: %v", + value, result, result, + ) + return + } + } +} + +func TestJsonValueToJsonableRefusesUnsafeIntegers(t *testing.T) { + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + } { + _, err := ourjsonization.JsonValueToJsonable( + []interface{}{ + map[string]interface{}{ + "key": value, + }, + }, + ) + if err == nil { + t.Fatalf("Expected an error when serializing %d, but got none", value) + return + } + + seriaErr, ok := err.(*ourjsonization.SerializationError) + if !ok { + t.Fatalf( + "Expected a *SerializationError, but got %T: %v", + err, err, + ) + return + } + + expected := unsafeIntegerMessage(value) + if seriaErr.Message != expected { + t.Fatalf( + "Expected the error %q, but got %q", + expected, seriaErr.Message, + ) + return + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/verification/verification.go index b305035b8..e2671a4b7 100644 --- a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/verification/verification.go @@ -12,7 +12,6 @@ package verification import ( "fmt" - "math" "sort" ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" @@ -91,6 +90,13 @@ func verifyJsonValue( return false } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both, as in the de/serialization. + if integer, ok := value.(int); ok { + value = int64(integer) + } + switch casted := value.(type) { case bool: return false @@ -98,23 +104,20 @@ func verifyJsonValue( case string: return false - case float64: - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though a float64 holds either. - if math.IsInf(casted, 0) || math.IsNaN(casted) { + case int64: + if !ourcommon.IsSafeInteger(casted) { return onError( - newVerificationError( - fmt.Sprintf( - "Expected a JSON-able value, but got the number %v, "+ - "which is neither finite nor representable in JSON", - casted, - ), - ), + newVerificationError(ourcommon.NotJsonAbleIntegerMessage(casted)), ) } return false + case float64: + if cause := ourcommon.CheckJsonAbleFloat64(casted); cause != "" { + return onError(newVerificationError(cause)) + } + return false + case ourtypes.JsonArray: for i, item := range casted { abort = verifyJsonValue( @@ -159,8 +162,8 @@ func verifyJsonValue( return onError( newVerificationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "a JsonArray or a JsonObject), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, a JsonArray or a JsonObject), but got: %T", value, ), ), diff --git a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/xmlrpc/xmlrpc.go b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/xmlrpc/xmlrpc.go index 9976acfba..d785b3b10 100644 --- a/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/xmlrpc/xmlrpc.go +++ b/dev/test_data/main/golang/expected/json_types_in_invariants/expected_output/xmlrpc/xmlrpc.go @@ -22,6 +22,7 @@ import ( "regexp" "sort" "strconv" + ourcommon "github.com/dummy-works/dummy/common" xmlcommon "github.com/dummy-works/dummy/internal/xmlcommon" ourtypes "github.com/dummy-works/dummy/types" ) @@ -499,6 +500,13 @@ func writeValueContent( return } + // NOTE (mristin): + // An int is widened to an int64 first, so that a single case below + // handles both, as in the JSON de/serialization and the verification. + if integer, ok := value.(int); ok { + value = int64(integer) + } + switch casted := value.(type) { case bool: // NOTE (mristin): @@ -518,6 +526,23 @@ func writeValueContent( ) return + case int64: + // NOTE (mristin): + // An integer is written as a ``, just as JSON knows a single + // numeric type, and hence has to lie in the safe integer range. + if !ourcommon.IsSafeInteger(casted) { + err = xmlcommon.NewSerializationError( + ourcommon.NotJsonAbleIntegerMessage(casted), + ) + return + } + err = xmlcommon.WriteElementInNoNamespace( + encoder, "double", undeclareNamespace, + float64(casted), + xmlcommon.WriteAsText_double, + ) + return + case float64: // NOTE (mristin): // JSON knows neither an infinity nor a not-a-number, so neither is @@ -567,8 +592,8 @@ func writeValueContent( default: err = xmlcommon.NewSerializationError( fmt.Sprintf( - "Expected a JSON-able value (a bool, a float64, a string, "+ - "a JsonArray or a JsonObject), but got: %T", + "Expected a JSON-able value (a bool, a float64, an int, an int64, "+ + "a string, a JsonArray or a JsonObject), but got: %T", value, ), ) diff --git a/dev/test_data/main/golang/expected/list_of_classes/expected_output/common/common.go b/dev/test_data/main/golang/expected/list_of_classes/expected_output/common/common.go index 6dc34e60c..c2047c736 100644 --- a/dev/test_data/main/golang/expected/list_of_classes/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/list_of_classes/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -231,5 +232,35 @@ func LenStr(text string) int { return utf8.RuneCountInString(text) } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/list_of_classes/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/list_of_classes/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/list_of_classes/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/list_of_classes/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/list_of_classes/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/list_of_classes/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/list_of_classes/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/list_of_classes/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/list_of_classes/expected_output/jsonization/jsonization.go index 76e54a709..03dbe6622 100644 --- a/dev/test_data/main/golang/expected/list_of_classes/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/list_of_classes/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.IAbstractItem], // or return an error. func AbstractItemFromJsonable( @@ -835,8 +816,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -845,17 +827,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/list_of_classes/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/list_of_classes/expected_output/jsonization/test/concrete_classes_test.go index 4bd870605..b10bc8c28 100644 --- a/dev/test_data/main/golang/expected/list_of_classes/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/list_of_classes/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -491,7 +556,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestAnotherItemSerializationFailOnOutOfRangeSerialNumber(t *testing.T) { +func TestAnotherItemRefusesUnsafeIntegerInSerialNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "AnotherItem") for _, value := range []int64{ @@ -509,11 +574,39 @@ func TestAnotherItemSerializationFailOnOutOfRangeSerialNumber(t *testing.T) { instance.SetSerialNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "SerialNumber", "SerialNumber()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestAnotherItemAcceptsSafeIntegerInSerialNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "AnotherItem") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.AnotherItemFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetSerialNumber(value) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/list_of_classes/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/list_of_classes/expected_output/verification/verification.go index c9e62fd42..c04361593 100644 --- a/dev/test_data/main/golang/expected/list_of_classes/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/list_of_classes/expected_output/verification/verification.go @@ -117,6 +117,22 @@ func IsInLocalCopyOfSimples( ) } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomeItem]. // // You have to supply the callback `onError` to iterate over the errors. @@ -146,7 +162,20 @@ func VerifyAnotherItem( ) (abort bool) { abort = false - // No verification has been defined for IAnotherItem. + abort = verifyInt64( + that.SerialNumber(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "SerialNumber", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } diff --git a/dev/test_data/main/golang/expected/list_of_constrained_primitives/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/list_of_constrained_primitives/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/list_of_constrained_primitives/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/list_of_constrained_primitives/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/list_of_constrained_primitives/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/list_of_constrained_primitives/expected_output/jsonization/jsonization.go index 62fd2c254..c39a950b1 100644 --- a/dev/test_data/main/golang/expected/list_of_constrained_primitives/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/list_of_constrained_primitives/expected_output/jsonization/jsonization.go @@ -126,66 +126,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -547,30 +487,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/list_of_enums/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/list_of_enums/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/list_of_enums/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/list_of_enums/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/list_of_enums/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/list_of_enums/expected_output/jsonization/jsonization.go index 41300eed2..3861c7d1f 100644 --- a/dev/test_data/main/golang/expected/list_of_enums/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/list_of_enums/expected_output/jsonization/jsonization.go @@ -127,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -600,30 +540,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/common/common.go b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/common/common.go index 6dc34e60c..c2047c736 100644 --- a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -231,5 +232,35 @@ func LenStr(text string) int { return utf8.RuneCountInString(text) } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/jsonization/jsonization.go index 2cfa3462e..97cf6ed25 100644 --- a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.ISomething], // or return an error. func SomethingFromJsonable( @@ -603,8 +584,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -613,17 +595,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/jsonization/test/concrete_classes_test.go index a5add0b10..9345d23b3 100644 --- a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/jsonization/test/concrete_classes_test.go @@ -4,14 +4,18 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "math" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -72,6 +76,102 @@ 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( + t *testing.T, + that ourtypes.IClass, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, +) { + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) + + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { + t.Fatalf( + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, + ) + return + } + + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { + t.Fatalf( + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, + ) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { + t.Fatalf( + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, + ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return + } +} + func TestSomethingRoundTripOK(t *testing.T) { pths := ourtesting.FindFilesBySuffixRecursively( filepath.Join( @@ -177,7 +277,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeSomeInts(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInSomeInts(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -195,14 +295,42 @@ func TestSomethingSerializationFailOnOutOfRangeSomeInts(t *testing.T) { instance.SetSomeInts([]int64{0, value}) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "SomeInts[1]", "SomeInts()[1]", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } +func TestSomethingAcceptsSafeIntegerInSomeInts(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetSomeInts([]int64{0, value}) + + assertSafeIntegerAccepted(t, instance) + } +} + func TestSomethingSerializationFailOnNonFiniteSomeFloats(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") diff --git a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/verification/verification.go index bea9bd7bb..907a2ae91 100644 --- a/dev/test_data/main/golang/expected/list_of_primitives/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/list_of_primitives/expected_output/verification/verification.go @@ -180,6 +180,22 @@ func ContainsDot( ) } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomething]. // // You have to supply the callback `onError` to iterate over the errors. @@ -370,6 +386,21 @@ func VerifySomething( if abort { return } + } else { + abort = verifyListOf_long( + that.SomeInts(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "SomeInts", + }, + ) + return onError(err) + }, + ) + if abort { + return + } } if that.SomeFloats() == nil { @@ -408,6 +439,31 @@ func VerifySomething( return } +// Verify the items of `that` recursively. +func verifyListOf_long( + that []int64, + onError func(*VerificationError) bool, +) (abort bool) { + for i, item := range that { + abort = verifyInt64( + item, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: i, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + + return +} + // Verify ``that`` instance. // // You have to supply the callback `onError` to iterate over the errors. diff --git a/dev/test_data/main/golang/expected/method_references/expected_output/common/common.go b/dev/test_data/main/golang/expected/method_references/expected_output/common/common.go index 6dc34e60c..c2047c736 100644 --- a/dev/test_data/main/golang/expected/method_references/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/method_references/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -231,5 +232,35 @@ func LenStr(text string) int { return utf8.RuneCountInString(text) } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/method_references/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/method_references/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/method_references/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/method_references/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/method_references/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/method_references/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/method_references/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/method_references/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/method_references/expected_output/jsonization/jsonization.go index f43f03439..83ac0d90b 100644 --- a/dev/test_data/main/golang/expected/method_references/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/method_references/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.IItem], // or return an error. func ItemFromJsonable( @@ -616,8 +597,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -626,17 +608,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/method_references/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/method_references/expected_output/jsonization/test/concrete_classes_test.go index 157181911..c03454ee7 100644 --- a/dev/test_data/main/golang/expected/method_references/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/method_references/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -176,7 +241,7 @@ func TestBoxDeserializationFail(t *testing.T) { } } -func TestBoxSerializationFailOnOutOfRangeSize(t *testing.T) { +func TestBoxRefusesUnsafeIntegerInSize(t *testing.T) { pth := mustFirstExpectedPath(t, "Box") for _, value := range []int64{ @@ -194,11 +259,39 @@ func TestBoxSerializationFailOnOutOfRangeSize(t *testing.T) { instance.SetSize(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Size", "Size()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestBoxAcceptsSafeIntegerInSize(t *testing.T) { + pth := mustFirstExpectedPath(t, "Box") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.BoxFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetSize(value) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/method_references/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/method_references/expected_output/verification/verification.go index bf9380fd4..9098190eb 100644 --- a/dev/test_data/main/golang/expected/method_references/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/method_references/expected_output/verification/verification.go @@ -54,6 +54,22 @@ func IsDescribed( return ourcommon.LenStr(text) > 0 } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.IBox]. // // You have to supply the callback `onError` to iterate over the errors. @@ -76,6 +92,21 @@ func VerifyBox( } } + abort = verifyInt64( + that.Size(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Size", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + return } diff --git a/dev/test_data/main/golang/expected/method_references/expected_output/xmlization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/method_references/expected_output/xmlization/test/concrete_classes_test.go index acb50f616..7c7f38e74 100644 --- a/dev/test_data/main/golang/expected/method_references/expected_output/xmlization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/method_references/expected_output/xmlization/test/concrete_classes_test.go @@ -200,5 +200,131 @@ func TestDuplicatePropertyFails(t *testing.T) { } } +// Give the text of the first recorded example of IBox with +// the content of the element `xmlName` replaced by `text`. +func patchBoxWith( + t *testing.T, + xmlName string, + text string, +) string { + pths := ourtesting.FindFilesBySuffixRecursively( + filepath.Join( + ourtesting.TestDataDir, + "Xml", + "Expected", + "box", + ), + ".xml", + ) + sort.Strings(pths) + + if len(pths) == 0 { + t.Fatalf( + "Expected at least one recorded example of %s, but got none", + "box", + ) + } + + bb, err := os.ReadFile(pths[0]) + if err != nil { + t.Fatalf("Failed to read the file %s: %s", pths[0], err.Error()) + } + + original := string(bb) + + start := strings.Index(original, "<"+xmlName+">") + len(xmlName) + 2 + end := strings.Index(original, "") + + return original[:start] + text + original[end:] +} + +// Read the first recorded example of IBox with the content of +// the element `xmlName` replaced by `text`. +func readBoxWith( + t *testing.T, + xmlName string, + text string, +) ourtypes.IBox { + patched := patchBoxWith(t, xmlName, text) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + deserialized, deseriaErr := ourxmlization.Unmarshal(decoder) + if deseriaErr != nil { + t.Fatalf( + "Expected no de-serialization error on %v, but got: %s", + patched, deseriaErr.Error(), + ) + } + + instance, ok := deserialized.(ourtypes.IBox) + if !ok { + t.Fatalf("Expected an instance of IBox, but got %T", deserialized) + } + + return instance +} + +func TestSizeReadFrom9007199254740991(t *testing.T) { + instance := readBoxWith( + t, + "size", + "9007199254740991", + ) + + got := instance.Size() + expected := int64(9007199254740991) + if got != expected { + t.Fatalf("Expected %v, but got %v", expected, got) + } +} + +func TestSizeReadFromMinus9007199254740991(t *testing.T) { + instance := readBoxWith( + t, + "size", + "-9007199254740991", + ) + + got := instance.Size() + expected := int64(-9007199254740991) + if got != expected { + t.Fatalf("Expected %v, but got %v", expected, got) + } +} + +func TestSizeReadFrom9007199254740992Fails(t *testing.T) { + patched := patchBoxWith( + t, + "size", + "9007199254740992", + ) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + _, err := ourxmlization.Unmarshal(decoder) + + expected := "Expected an integer, but got 9007199254740992, which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + deseriaErr, ok := err.(*ourxmlization.DeserializationError) + if !ok || deseriaErr.Message != expected { + t.Fatalf("Expected the de-serialization error %q, but got: %v", expected, err) + } +} + +func TestSizeReadFromMinus9007199254740992Fails(t *testing.T) { + patched := patchBoxWith( + t, + "size", + "-9007199254740992", + ) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + _, err := ourxmlization.Unmarshal(decoder) + + expected := "Expected an integer, but got -9007199254740992, which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + deseriaErr, ok := err.(*ourxmlization.DeserializationError) + if !ok || deseriaErr.Message != expected { + t.Fatalf("Expected the de-serialization error %q, but got: %v", expected, err) + } +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/nested_any_in_all/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/nested_any_in_all/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/nested_any_in_all/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/nested_any_in_all/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/nested_any_in_all/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/nested_any_in_all/expected_output/jsonization/jsonization.go index 9a2691191..7dbe84f7e 100644 --- a/dev/test_data/main/golang/expected/nested_any_in_all/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/nested_any_in_all/expected_output/jsonization/jsonization.go @@ -126,66 +126,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -941,30 +881,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/nested_collections/expected_output/common/common.go b/dev/test_data/main/golang/expected/nested_collections/expected_output/common/common.go index e33d7091f..f9e57115a 100644 --- a/dev/test_data/main/golang/expected/nested_collections/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/nested_collections/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "sort" "strings" "unicode/utf8" @@ -314,5 +315,35 @@ func LessOrdered[T int64 | string](that T, other T) bool { return that < other } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/nested_collections/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/nested_collections/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/nested_collections/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/nested_collections/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/nested_collections/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/nested_collections/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/nested_collections/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/nested_collections/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/nested_collections/expected_output/jsonization/jsonization.go index 9ea1d1337..7e3c6c810 100644 --- a/dev/test_data/main/golang/expected/nested_collections/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/nested_collections/expected_output/jsonization/jsonization.go @@ -128,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -400,6 +340,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Report that `jsonable` is no text of a literal of the enumeration `enumName`. func notAnEnumTextError( jsonable interface{}, @@ -1156,8 +1136,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -1166,17 +1147,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/nested_collections/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/nested_collections/expected_output/jsonization/test/concrete_classes_test.go index 06bf2f04c..2698337ab 100644 --- a/dev/test_data/main/golang/expected/nested_collections/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/nested_collections/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -386,7 +451,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestAnotherItemSerializationFailOnOutOfRangeSerialNumber(t *testing.T) { +func TestAnotherItemRefusesUnsafeIntegerInSerialNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "AnotherItem") for _, value := range []int64{ @@ -404,11 +469,39 @@ func TestAnotherItemSerializationFailOnOutOfRangeSerialNumber(t *testing.T) { instance.SetSerialNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "SerialNumber", "SerialNumber()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestAnotherItemAcceptsSafeIntegerInSerialNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "AnotherItem") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.AnotherItemFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetSerialNumber(value) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/nested_collections/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/nested_collections/expected_output/verification/verification.go index 2a97a0d1b..e743b75d1 100644 --- a/dev/test_data/main/golang/expected/nested_collections/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/nested_collections/expected_output/verification/verification.go @@ -115,6 +115,22 @@ func ResetCorner( return true } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomeItem]. // // You have to supply the callback `onError` to iterate over the errors. @@ -152,7 +168,20 @@ func VerifyAnotherItem( ) (abort bool) { abort = false - // No verification has been defined for IAnotherItem. + abort = verifyInt64( + that.SerialNumber(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "SerialNumber", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } @@ -296,6 +325,21 @@ func VerifySomething( if abort { return } + } else { + abort = verifyListOf_ListOf_long( + that.Grid(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Grid", + }, + ) + return onError(err) + }, + ) + if abort { + return + } } if that.RowsOfPairs() == nil { @@ -411,6 +455,56 @@ func VerifySomething( return } +// Verify the items of `that` recursively. +func verifyListOf_ListOf_long( + that [][]int64, + onError func(*VerificationError) bool, +) (abort bool) { + for i, item := range that { + abort = verifyListOf_long( + item, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: i, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + + return +} + +// Verify the items of `that` recursively. +func verifyListOf_long( + that []int64, + onError func(*VerificationError) bool, +) (abort bool) { + for i, item := range that { + abort = verifyInt64( + item, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: i, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + + return +} + // Verify the items of `that` recursively. func verifyListOf_ListOf_TupleOf2_PositiveInt_SetOf_string( that [][]ourcommon.Tuple2[int64, map[string]struct{}], @@ -595,6 +689,21 @@ func verifyTupleOf2_long_Code( that ourcommon.Tuple2[int64, string], onError func(*VerificationError) bool, ) (abort bool) { + abort = verifyInt64( + that.Item1, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: 0, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + abort = VerifyCode( that.Item2, func(err *VerificationError) bool { @@ -754,6 +863,13 @@ func VerifyPositiveInt( ) (abort bool) { abort = false + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + if !(that > 0) { abort = onError( newVerificationError( diff --git a/dev/test_data/main/golang/expected/optional_arguments/expected_output/common/common.go b/dev/test_data/main/golang/expected/optional_arguments/expected_output/common/common.go index 6dc34e60c..c2047c736 100644 --- a/dev/test_data/main/golang/expected/optional_arguments/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/optional_arguments/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -231,5 +232,35 @@ func LenStr(text string) int { return utf8.RuneCountInString(text) } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/optional_arguments/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/optional_arguments/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/optional_arguments/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/optional_arguments/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/optional_arguments/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/optional_arguments/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/optional_arguments/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/optional_arguments/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/optional_arguments/expected_output/jsonization/jsonization.go index 4427dce0e..6d5155098 100644 --- a/dev/test_data/main/golang/expected/optional_arguments/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/optional_arguments/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourstringification "github.com/dummy-works/dummy/stringification" ourtypes "github.com/dummy-works/dummy/types" @@ -127,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -399,6 +340,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Report that `jsonable` is no text of a literal of the enumeration `enumName`. func notAnEnumTextError( jsonable interface{}, @@ -707,8 +688,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -717,17 +699,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/optional_arguments/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/optional_arguments/expected_output/verification/verification.go index 8f1329528..e6024247d 100644 --- a/dev/test_data/main/golang/expected/optional_arguments/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/optional_arguments/expected_output/verification/verification.go @@ -116,6 +116,22 @@ func NoteIsShort( ourcommon.LenStr(*note) <= 5 } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.IItem]. // // You have to supply the callback `onError` to iterate over the errors. @@ -241,6 +257,23 @@ func VerifySomething( } } + if that.OptionalNumber() != nil { + abort = verifyInt64( + *that.OptionalNumber(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "OptionalNumber", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + if that.OptionalItem() != nil { abort = Verify( that.OptionalItem(), diff --git a/dev/test_data/main/golang/expected/primitive_types/expected_output/common/common.go b/dev/test_data/main/golang/expected/primitive_types/expected_output/common/common.go index 9cd0eae53..ffc9e4cb4 100644 --- a/dev/test_data/main/golang/expected/primitive_types/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/primitive_types/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" ) @@ -221,5 +222,35 @@ func (tuple Tuple8[T1, T2, T3, T4, T5, T6, T7, T8]) Len() int { return 8 } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/primitive_types/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/primitive_types/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/primitive_types/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/primitive_types/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/primitive_types/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/primitive_types/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/primitive_types/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/primitive_types/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/primitive_types/expected_output/jsonization/jsonization.go index 4d7dfdb9f..ab992b6ea 100644 --- a/dev/test_data/main/golang/expected/primitive_types/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/primitive_types/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.ISomething], // or return an error. func SomethingFromJsonable( @@ -603,8 +584,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -613,17 +595,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/primitive_types/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/primitive_types/expected_output/jsonization/test/concrete_classes_test.go index 5120f8217..7bf592fa1 100644 --- a/dev/test_data/main/golang/expected/primitive_types/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/primitive_types/expected_output/jsonization/test/concrete_classes_test.go @@ -4,14 +4,18 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "math" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -72,6 +76,102 @@ 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( + t *testing.T, + that ourtypes.IClass, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, +) { + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) + + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { + t.Fatalf( + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, + ) + return + } + + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { + t.Fatalf( + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, + ) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { + t.Fatalf( + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, + ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return + } +} + func TestSomethingRoundTripOK(t *testing.T) { pths := ourtesting.FindFilesBySuffixRecursively( filepath.Join( @@ -177,7 +277,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeSomeInt(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInSomeInt(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -195,14 +295,42 @@ func TestSomethingSerializationFailOnOutOfRangeSomeInt(t *testing.T) { instance.SetSomeInt(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "SomeInt", "SomeInt()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), ) } } +func TestSomethingAcceptsSafeIntegerInSomeInt(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, + ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetSomeInt(value) + + assertSafeIntegerAccepted(t, instance) + } +} + func TestSomethingSerializationFailOnNonFiniteSomeFloat(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") diff --git a/dev/test_data/main/golang/expected/primitive_types/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/primitive_types/expected_output/verification/verification.go index 39dea9f70..81366cd93 100644 --- a/dev/test_data/main/golang/expected/primitive_types/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/primitive_types/expected_output/verification/verification.go @@ -12,6 +12,7 @@ package verification import ( "fmt" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -44,6 +45,22 @@ func (ve *VerificationError) PathString() string { return ourreporting.ToGolangPath(ve.Path) } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomething]. // // You have to supply the callback `onError` to iterate over the errors. @@ -56,6 +73,21 @@ func VerifySomething( ) (abort bool) { abort = false + abort = verifyInt64( + that.SomeInt(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "SomeInt", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + if that.SomeBytes() == nil { abort = onError( newVerificationError( diff --git a/dev/test_data/main/golang/expected/primitive_types/expected_output/xmlization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/primitive_types/expected_output/xmlization/test/concrete_classes_test.go index e40e4c204..ab3a3a1b9 100644 --- a/dev/test_data/main/golang/expected/primitive_types/expected_output/xmlization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/primitive_types/expected_output/xmlization/test/concrete_classes_test.go @@ -201,13 +201,13 @@ func TestDuplicatePropertyFails(t *testing.T) { } } -// Read the first recorded example of ISomething with the content of -// the element `xmlName` replaced by `text`. -func readSomethingWith( +// Give the text of the first recorded example of ISomething with +// the content of the element `xmlName` replaced by `text`. +func patchSomethingWith( t *testing.T, xmlName string, text string, -) ourtypes.ISomething { +) string { pths := ourtesting.FindFilesBySuffixRecursively( filepath.Join( ourtesting.TestDataDir, @@ -236,7 +236,17 @@ func readSomethingWith( start := strings.Index(original, "<"+xmlName+">") + len(xmlName) + 2 end := strings.Index(original, "") - patched := original[:start] + text + original[end:] + return original[:start] + text + original[end:] +} + +// Read the first recorded example of ISomething with the content of +// the element `xmlName` replaced by `text`. +func readSomethingWith( + t *testing.T, + xmlName string, + text string, +) ourtypes.ISomething { + patched := patchSomethingWith(t, xmlName, text) decoder := xml.NewDecoder(strings.NewReader(patched)) deserialized, deseriaErr := ourxmlization.Unmarshal(decoder) @@ -255,6 +265,68 @@ func readSomethingWith( return instance } +func TestSomeIntReadFrom9007199254740991(t *testing.T) { + instance := readSomethingWith( + t, + "someInt", + "9007199254740991", + ) + + got := instance.SomeInt() + expected := int64(9007199254740991) + if got != expected { + t.Fatalf("Expected %v, but got %v", expected, got) + } +} + +func TestSomeIntReadFromMinus9007199254740991(t *testing.T) { + instance := readSomethingWith( + t, + "someInt", + "-9007199254740991", + ) + + got := instance.SomeInt() + expected := int64(-9007199254740991) + if got != expected { + t.Fatalf("Expected %v, but got %v", expected, got) + } +} + +func TestSomeIntReadFrom9007199254740992Fails(t *testing.T) { + patched := patchSomethingWith( + t, + "someInt", + "9007199254740992", + ) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + _, err := ourxmlization.Unmarshal(decoder) + + expected := "Expected an integer, but got 9007199254740992, which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + deseriaErr, ok := err.(*ourxmlization.DeserializationError) + if !ok || deseriaErr.Message != expected { + t.Fatalf("Expected the de-serialization error %q, but got: %v", expected, err) + } +} + +func TestSomeIntReadFromMinus9007199254740992Fails(t *testing.T) { + patched := patchSomethingWith( + t, + "someInt", + "-9007199254740992", + ) + + decoder := xml.NewDecoder(strings.NewReader(patched)) + _, err := ourxmlization.Unmarshal(decoder) + + expected := "Expected an integer, but got -9007199254740992, which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + deseriaErr, ok := err.(*ourxmlization.DeserializationError) + if !ok || deseriaErr.Message != expected { + t.Fatalf("Expected the de-serialization error %q, but got: %v", expected, err) + } +} + func TestSomeFloatReadFrom1e400(t *testing.T) { instance := readSomethingWith( t, diff --git a/dev/test_data/main/golang/expected/problematic_keywords/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/problematic_keywords/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/problematic_keywords/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/problematic_keywords/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/problematic_keywords/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/problematic_keywords/expected_output/jsonization/jsonization.go index 3ddf76b4e..3de426345 100644 --- a/dev/test_data/main/golang/expected/problematic_keywords/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/problematic_keywords/expected_output/jsonization/jsonization.go @@ -126,66 +126,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -589,30 +529,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/sets/expected_output/common/common.go b/dev/test_data/main/golang/expected/sets/expected_output/common/common.go index 227789f65..39d73bf53 100644 --- a/dev/test_data/main/golang/expected/sets/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/sets/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "sort" "strings" "unicode/utf8" @@ -342,5 +343,35 @@ func LessOrdered[T int64 | string](that T, other T) bool { return that < other } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/sets/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/sets/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/sets/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/sets/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/sets/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/sets/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/sets/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/sets/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/sets/expected_output/jsonization/jsonization.go index 3ea20a217..106cd6061 100644 --- a/dev/test_data/main/golang/expected/sets/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/sets/expected_output/jsonization/jsonization.go @@ -128,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -400,6 +340,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Report that `jsonable` is no text of a literal of the enumeration `enumName`. func notAnEnumTextError( jsonable interface{}, @@ -909,8 +889,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -919,17 +900,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/sets/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/sets/expected_output/jsonization/test/concrete_classes_test.go index a5775adfd..48edf7af1 100644 --- a/dev/test_data/main/golang/expected/sets/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/sets/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -281,7 +346,7 @@ func TestCollectionDeserializationFail(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -299,15 +364,43 @@ func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { instance.SetNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Number", "Number()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumber(value) + + assertSafeIntegerAccepted(t, instance) } } -func TestSomethingSerializationFailOnOutOfRangeNumbers(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInNumbers(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -325,11 +418,39 @@ func TestSomethingSerializationFailOnOutOfRangeNumbers(t *testing.T) { instance.SetNumbers([]int64{0, value}) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Numbers[1]", "Numbers()[1]", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInNumbers(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumbers([]int64{0, value}) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/sets/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/sets/expected_output/verification/verification.go index a244f1f30..deb03be82 100644 --- a/dev/test_data/main/golang/expected/sets/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/sets/expected_output/verification/verification.go @@ -329,6 +329,22 @@ func SomeKindIsNotSpecial( ) > 0 } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomething]. // // You have to supply the callback `onError` to iterate over the errors. @@ -532,6 +548,21 @@ func VerifySomething( } } + abort = verifyInt64( + that.Number(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Number", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + abort = VerifyKind( that.Kind(), func(err *VerificationError) bool { @@ -567,6 +598,21 @@ func VerifySomething( if abort { return } + } else { + abort = verifyListOf_long( + that.Numbers(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Numbers", + }, + ) + return onError(err) + }, + ) + if abort { + return + } } if that.Kinds() == nil { @@ -765,6 +811,21 @@ func VerifyCollection( if abort { return } + } else { + abort = verifySetOf_long( + that.Numbers(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Numbers", + }, + ) + return onError(err) + }, + ) + if abort { + return + } } if that.Flags() == nil { @@ -850,6 +911,31 @@ func VerifyCollection( return } +// Verify the items of `that` recursively. +func verifyListOf_long( + that []int64, + onError func(*VerificationError) bool, +) (abort bool) { + for i, item := range that { + abort = verifyInt64( + item, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: i, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + + return +} + // Verify the items of `that` recursively. func verifyListOf_Kind( that []ourtypes.Kind, @@ -900,6 +986,31 @@ func verifyListOf_Code( return } +// Verify the items of `that` recursively. +func verifySetOf_long( + that map[int64]struct{}, + onError func(*VerificationError) bool, +) (abort bool) { + for i, item := range ourcommon.SortedKeys(that, ourcommon.LessOrdered[int64]) { + abort = verifyInt64( + item, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: i, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + } + + return +} + // Verify the items of `that` recursively. func verifySetOf_Direction( that map[ourtypes.Direction]struct{}, diff --git a/dev/test_data/main/golang/expected/string_operations/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/string_operations/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/string_operations/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/string_operations/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/string_operations/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/string_operations/expected_output/jsonization/jsonization.go index f4eb99384..763e3e342 100644 --- a/dev/test_data/main/golang/expected/string_operations/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/string_operations/expected_output/jsonization/jsonization.go @@ -126,66 +126,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -603,30 +543,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/switch_statements/expected_output/common/common.go b/dev/test_data/main/golang/expected/switch_statements/expected_output/common/common.go index 6dc34e60c..c2047c736 100644 --- a/dev/test_data/main/golang/expected/switch_statements/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/switch_statements/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -231,5 +232,35 @@ func LenStr(text string) int { return utf8.RuneCountInString(text) } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/switch_statements/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/switch_statements/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/switch_statements/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/switch_statements/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/switch_statements/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/switch_statements/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/switch_statements/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/switch_statements/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/switch_statements/expected_output/jsonization/jsonization.go index 142559b75..badfb62d0 100644 --- a/dev/test_data/main/golang/expected/switch_statements/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/switch_statements/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourstringification "github.com/dummy-works/dummy/stringification" ourtypes "github.com/dummy-works/dummy/types" @@ -127,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -399,6 +340,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Report that `jsonable` is no text of a literal of the enumeration `enumName`. func notAnEnumTextError( jsonable interface{}, @@ -642,8 +623,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -652,17 +634,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/switch_statements/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/switch_statements/expected_output/jsonization/test/concrete_classes_test.go index 5f97ad234..9376399bf 100644 --- a/dev/test_data/main/golang/expected/switch_statements/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/switch_statements/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -176,7 +241,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -194,11 +259,39 @@ func TestSomethingSerializationFailOnOutOfRangeNumber(t *testing.T) { instance.SetNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Number", "Number()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetNumber(value) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/switch_statements/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/switch_statements/expected_output/verification/verification.go index cfc18e9ea..7d9690960 100644 --- a/dev/test_data/main/golang/expected/switch_statements/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/switch_statements/expected_output/verification/verification.go @@ -218,6 +218,22 @@ func SwitchWithReassignedInt( return result < 1000 } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomething]. // // You have to supply the callback `onError` to iterate over the errors. @@ -375,6 +391,21 @@ func VerifySomething( return } + abort = verifyInt64( + that.Number(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Number", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + return } diff --git a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/common/common.go b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/common/common.go index df9f90d55..d6209808e 100644 --- a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -329,5 +330,35 @@ func AbsInt64(x int64) int64 { return x } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/jsonization/jsonization.go index 9e9f81c32..4fccb7d95 100644 --- a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourstringification "github.com/dummy-works/dummy/stringification" ourtypes "github.com/dummy-works/dummy/types" @@ -127,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -399,6 +340,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Report that `jsonable` is no text of a literal of the enumeration `enumName`. func notAnEnumTextError( jsonable interface{}, @@ -786,8 +767,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -796,17 +778,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/jsonization/test/concrete_classes_test.go index 209c550e3..bdc317aa6 100644 --- a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -281,7 +346,7 @@ func TestSecondDeserializationFail(t *testing.T) { } } -func TestFirstSerializationFailOnOutOfRangeCount(t *testing.T) { +func TestFirstRefusesUnsafeIntegerInCount(t *testing.T) { pth := mustFirstExpectedPath(t, "First") for _, value := range []int64{ @@ -299,15 +364,43 @@ func TestFirstSerializationFailOnOutOfRangeCount(t *testing.T) { instance.SetCount(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Count", "Count()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestFirstAcceptsSafeIntegerInCount(t *testing.T) { + pth := mustFirstExpectedPath(t, "First") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.FirstFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetCount(value) + + assertSafeIntegerAccepted(t, instance) } } -func TestSecondSerializationFailOnOutOfRangeCount(t *testing.T) { +func TestSecondRefusesUnsafeIntegerInCount(t *testing.T) { pth := mustFirstExpectedPath(t, "Second") for _, value := range []int64{ @@ -325,11 +418,39 @@ func TestSecondSerializationFailOnOutOfRangeCount(t *testing.T) { instance.SetCount(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Count", "Count()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSecondAcceptsSafeIntegerInCount(t *testing.T) { + pth := mustFirstExpectedPath(t, "Second") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SecondFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetCount(value) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/verification/verification.go index 28cb4f032..e66a0919b 100644 --- a/dev/test_data/main/golang/expected/transpiled_methods/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/transpiled_methods/expected_output/verification/verification.go @@ -58,6 +58,22 @@ func ResetAndCheck( item.IsEmpty() } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.IFirst]. // // You have to supply the callback `onError` to iterate over the errors. @@ -121,6 +137,21 @@ func VerifyFirst( } } + abort = verifyInt64( + that.Count(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Count", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + if that.Kind() != nil { abort = VerifyKind( *that.Kind(), @@ -214,6 +245,21 @@ func VerifySecond( } } + abort = verifyInt64( + that.Count(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Count", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + if that.Kind() != nil { abort = VerifyKind( *that.Kind(), diff --git a/dev/test_data/main/golang/expected/tuples/expected_output/common/common.go b/dev/test_data/main/golang/expected/tuples/expected_output/common/common.go index 9cd0eae53..ffc9e4cb4 100644 --- a/dev/test_data/main/golang/expected/tuples/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/tuples/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" ) @@ -221,5 +222,35 @@ func (tuple Tuple8[T1, T2, T3, T4, T5, T6, T7, T8]) Len() int { return 8 } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/tuples/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/tuples/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/tuples/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/tuples/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/tuples/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/tuples/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/tuples/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/tuples/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/tuples/expected_output/jsonization/jsonization.go index 6802f3890..fb5f84ebd 100644 --- a/dev/test_data/main/golang/expected/tuples/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/tuples/expected_output/jsonization/jsonization.go @@ -128,66 +128,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -400,6 +340,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Report that `jsonable` is no text of a literal of the enumeration `enumName`. func notAnEnumTextError( jsonable interface{}, @@ -979,8 +959,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -989,17 +970,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/tuples/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/tuples/expected_output/jsonization/test/concrete_classes_test.go index 3868133e2..164c85a50 100644 --- a/dev/test_data/main/golang/expected/tuples/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/tuples/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -386,7 +451,7 @@ func TestSomethingDeserializationFail(t *testing.T) { } } -func TestAnotherItemSerializationFailOnOutOfRangeSerialNumber(t *testing.T) { +func TestAnotherItemRefusesUnsafeIntegerInSerialNumber(t *testing.T) { pth := mustFirstExpectedPath(t, "AnotherItem") for _, value := range []int64{ @@ -404,15 +469,43 @@ func TestAnotherItemSerializationFailOnOutOfRangeSerialNumber(t *testing.T) { instance.SetSerialNumber(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "SerialNumber", "SerialNumber()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestAnotherItemAcceptsSafeIntegerInSerialNumber(t *testing.T) { + pth := mustFirstExpectedPath(t, "AnotherItem") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.AnotherItemFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetSerialNumber(value) + + assertSafeIntegerAccepted(t, instance) } } -func TestSomethingSerializationFailOnOutOfRangePair(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInPair(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -432,15 +525,45 @@ func TestSomethingSerializationFailOnOutOfRangePair(t *testing.T) { tuple.Item2 = value instance.SetPair(tuple) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Pair[1]", "Pair()[1]", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInPair(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + tuple := instance.Pair() + tuple.Item2 = value + instance.SetPair(tuple) + + assertSafeIntegerAccepted(t, instance) } } -func TestSomethingSerializationFailOnOutOfRangeTricky(t *testing.T) { +func TestSomethingRefusesUnsafeIntegerInTricky(t *testing.T) { pth := mustFirstExpectedPath(t, "Something") for _, value := range []int64{ @@ -460,11 +583,41 @@ func TestSomethingSerializationFailOnOutOfRangeTricky(t *testing.T) { tuple.Item1 = value instance.SetTricky(tuple) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Tricky[0]", "Tricky()[0]", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSomethingAcceptsSafeIntegerInTricky(t *testing.T) { + pth := mustFirstExpectedPath(t, "Something") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SomethingFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + tuple := instance.Tricky() + tuple.Item1 = value + instance.SetTricky(tuple) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/tuples/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/tuples/expected_output/verification/verification.go index 2a18ebf51..dbfc18d98 100644 --- a/dev/test_data/main/golang/expected/tuples/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/tuples/expected_output/verification/verification.go @@ -75,6 +75,22 @@ func NarrowedPairIsPositive( return pair.Item2 > 0 } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomeItem]. // // You have to supply the callback `onError` to iterate over the errors. @@ -104,7 +120,20 @@ func VerifyAnotherItem( ) (abort bool) { abort = false - // No verification has been defined for IAnotherItem. + abort = verifyInt64( + that.SerialNumber(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "SerialNumber", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } @@ -153,6 +182,21 @@ func VerifySomething( } } + abort = verifyTupleOf2_string_long( + that.Pair(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Pair", + }, + ) + return onError(err) + }, + ) + if abort { + return + } + abort = verifyTupleOf2_IAbstractItem_IAbstractItem( that.Items(), func(err *VerificationError) bool { @@ -203,6 +247,29 @@ func VerifySomething( return } +// Verify the items of `that` recursively. +func verifyTupleOf2_string_long( + that ourcommon.Tuple2[string, int64], + onError func(*VerificationError) bool, +) (abort bool) { + abort = verifyInt64( + that.Item2, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: 1, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + + return +} + // Verify the items of `that` recursively. func verifyTupleOf2_IAbstractItem_IAbstractItem( that ourcommon.Tuple2[ourtypes.IAbstractItem, ourtypes.IAbstractItem], @@ -246,6 +313,21 @@ func verifyTupleOf6_long_ISomeItem_IAbstractItem_ISomeItem_PositiveInt_Result( that ourcommon.Tuple6[int64, ourtypes.ISomeItem, ourtypes.IAbstractItem, ourtypes.ISomeItem, int64, ourtypes.Result], onError func(*VerificationError) bool, ) (abort bool) { + abort = verifyInt64( + that.Item1, + func(err *VerificationError) bool { + err.Path.PrependIndex( + &ourreporting.IndexSegment{ + Index: 0, + }, + ) + return onError(err) + }, + ) + if abort { + return + } + abort = Verify( that.Item2, func(err *VerificationError) bool { @@ -388,6 +470,13 @@ func VerifyPositiveInt( ) (abort bool) { abort = false + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + if !(that > 0) { abort = onError( newVerificationError( diff --git a/dev/test_data/main/golang/expected/unions/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/unions/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/unions/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/unions/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/unions/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/unions/expected_output/jsonization/jsonization.go index e923e7304..8a596163a 100644 --- a/dev/test_data/main/golang/expected/unions/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/unions/expected_output/jsonization/jsonization.go @@ -127,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -1709,30 +1649,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/common/common.go b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/common/common.go index 64ff930a0..27b8b0c32 100644 --- a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/common/common.go +++ b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/common/common.go @@ -5,6 +5,7 @@ package common // Do NOT edit or append. import ( + "fmt" "strings" "unicode/utf8" ) @@ -255,5 +256,35 @@ func AllKeys[K comparable](condition func(K) bool, set map[K]struct{}) bool { return true } +// Check that `value` lies in the safe integer range [-2^53 + 1, 2^53 - 1]. +// +// JSON knows a single numeric type, which the SDKs read into a float64. +// Outside of the range, a float64 can not tell an integer from its neighbour, +// so a reader might quietly get a different number than the one which was +// given. +func IsSafeInteger(value int64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Check that the whole `value` lies in the safe integer range +// [-2^53 + 1, 2^53 - 1]. +// +// This is [IsSafeInteger] for a float64, which might lie beyond the int64 +// range, and hence can not be converted to an int64 first. Both bounds are +// exact in a float64. +func IsSafeIntegerFloat64(value float64) bool { + return -9007199254740991 <= value && value <= 9007199254740991 +} + +// Describe why the integer `value`, an int64 or a whole float64, can not be +// serialized to JSON, as it is not a safe integer, see [IsSafeInteger]. +func UnsafeIntegerMessage(value interface{}) string { + return fmt.Sprintf( + "Expected an integer, but got %v, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ) +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/common/safe_integer_test/safe_integer_test.go b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/common/safe_integer_test/safe_integer_test.go new file mode 100644 index 000000000..ed85e343d --- /dev/null +++ b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/common/safe_integer_test/safe_integer_test.go @@ -0,0 +1,59 @@ +package common_safe_integer_test + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. + +import ( + "math" + "testing" + ourcommon "github.com/dummy-works/dummy/common" +) + +func TestIsSafeInteger(t *testing.T) { + for _, value := range []int64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []int64{ + 9007199254740992, + -9007199254740992, + math.MaxInt64, + math.MinInt64, + } { + if ourcommon.IsSafeInteger(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +func TestIsSafeIntegerFloat64(t *testing.T) { + for _, value := range []float64{ + 0, + 9007199254740991, + -9007199254740991, + } { + if !ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v to be a safe integer, but it is not", value) + } + } + + for _, value := range []float64{ + 9007199254740992, + -9007199254740992, + 1e300, + -1e300, + } { + if ourcommon.IsSafeIntegerFloat64(value) { + t.Errorf("Expected %v not to be a safe integer, but it is", value) + } + } +} + +// This code has been automatically generated by aas-core-codegen. +// Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..dff4477d9 100644 --- a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. @@ -1058,5 +1012,69 @@ func WriteElement[T any]( return } +// Consume the text tokens (char data) as a representation of a `xs:long`. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] as +// well, see [ourcommon.IsSafeInteger], so that it can be serialized to JSON. +// +// Any comment tokens are skipped. +// +// The resulting `next` token points to the first token which is neither text +// nor comment. +// +// If we reached the end-of-file, `next` is an [Eof] sentinel token. +func ReadTextAs_long( + decoder *xml.Decoder, + current xml.Token, +) (value int64, next xml.Token, err error) { + var text string + text, next, err = ReadText(decoder, current) + if err != nil { + return + } + text = CollapseWhitespace(text) + + var parseErr error + value, parseErr = strconv.ParseInt(text, 10, 64) + if parseErr != nil { + err = NewDeserializationError( + fmt.Sprintf( + "Expected a value as xs:long, but it could not be parsed: %s: %s", + parseErr.Error(), text, + ), + ) + return + } + + if !ourcommon.IsSafeInteger(value) { + err = NewDeserializationError(ourcommon.UnsafeIntegerMessage(value)) + value = 0 + return + } + + return +} + +// Write the `value` as a `xs:long` in a text element. +// +// The value has to lie in the safe integer range [-2^53 + 1, 2^53 - 1], see +// [ourcommon.IsSafeInteger], so that the XML and the JSON agree on what +// can be serialized. +// +// Do not flush. +func WriteAsText_long( + encoder *xml.Encoder, + value int64, +) (err error) { + if !ourcommon.IsSafeInteger(value) { + err = NewSerializationError(ourcommon.UnsafeIntegerMessage(value)) + return + } + + text := strconv.FormatInt(value, 10) + err = WriteText(encoder, text) + return +} + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/jsonization/jsonization.go index 5e5677859..65c275cb3 100644 --- a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/jsonization/jsonization.go @@ -16,6 +16,7 @@ import ( "fmt" "math" b64 "encoding/base64" + ourcommon "github.com/dummy-works/dummy/common" ourreporting "github.com/dummy-works/dummy/reporting" ourtypes "github.com/dummy-works/dummy/types" ) @@ -126,66 +127,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -398,6 +339,46 @@ func parseArray[T any]( return } +// Parse `jsonable` as a 64-bit integer, or return an error. +// +// A whole float64 such as `5.0` or `1e2` is accepted, but only within +// the safe integer range [-2^53 + 1, 2^53 - 1], as checked by +// [ourcommon.IsSafeIntegerFloat64]. +func int64FromJsonable( + jsonable interface{}, +) (result int64, err error) { + f, ok := jsonable.(float64) + if !ok { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got: %T", + jsonable, + ), + ) + return + } + + // NOTE (mristin): + // A not-a-number is never equal to its truncation. + if math.IsInf(f, 0) || f != math.Trunc(f) { + err = newDeserializationError( + fmt.Sprintf( + "Expected an integer, but got %v, which is not a whole number", + f, + ), + ) + return + } + + if !ourcommon.IsSafeIntegerFloat64(f) { + err = newDeserializationError(ourcommon.UnsafeIntegerMessage(f)) + return + } + + result = int64(f) + return +} + // Parse `jsonable` as an instance of [ourtypes.IItem], // or return an error. func ItemFromJsonable( @@ -890,8 +871,9 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. +// Cast `that` to a float64 and box it as a JSON-able value, or return +// an error if `that` is outside the safe integer range, as checked by +// [ourcommon.IsSafeInteger]. // // The result is returned as `interface{}`, not the more specific // `float64`, so that this function itself can be passed on as a bare @@ -900,17 +882,12 @@ func mustSerializationError(err error) *SerializationError { func int64ToJsonable( that int64, ) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) + if !ourcommon.IsSafeInteger(that) { + err = newSerializationError(ourcommon.UnsafeIntegerMessage(that)) return } - result = float64(that); + result = float64(that) return } diff --git a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/jsonization/test/concrete_classes_test.go b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/jsonization/test/concrete_classes_test.go index e1f2ee7ea..112364349 100644 --- a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/jsonization/test/concrete_classes_test.go +++ b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/jsonization/test/concrete_classes_test.go @@ -4,13 +4,17 @@ package jsonization_test // Do NOT edit or append. import ( + "encoding/xml" "fmt" "path/filepath" "sort" + "strings" "testing" ourjsonization "github.com/dummy-works/dummy/jsonization" ourtesting "github.com/dummy-works/dummy/ourtesting" ourtypes "github.com/dummy-works/dummy/types" + ourverification "github.com/dummy-works/dummy/verification" + ourxmlization "github.com/dummy-works/dummy/xmlization" ) // Determine the path to the first recorded example of `modelType`. @@ -36,38 +40,99 @@ func mustFirstExpectedPath(t *testing.T, modelType string) string { return pths[0] } -// 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( t *testing.T, that ourtypes.IClass, - expectedPath string, + expectedVerificationPath string, + expectedSerializationPath string, + expectedMessage string, ) { - _, err := ourjsonization.ToJsonable(that) + var veriErrs []*ourverification.VerificationError + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + veriErrs = append(veriErrs, err) + return + }, + ) - if err == nil { + found := false + for _, veriErr := range veriErrs { + if veriErr.PathString() == expectedVerificationPath && + veriErr.Message == expectedMessage { + found = true + break + } + } + if !found { t.Fatalf( - "Expected the serialization to fail at %s, but it succeeded", - expectedPath, + "Expected the verification to report at %s: %s, but got: %v", + expectedVerificationPath, expectedMessage, veriErrs, ) return } - seriaErr, ok := err.(*ourjsonization.SerializationError) - if !ok { + _, err := ourjsonization.ToJsonable(that) + jsonErr, ok := err.(*ourjsonization.SerializationError) + if !ok || + jsonErr.PathString() != expectedSerializationPath || + jsonErr.Message != expectedMessage { t.Fatalf( - "Expected a *SerializationError, but got %T: %v", - err, err, + "Expected the JSON serialization to fail at %s: %s, but got: %v", + expectedSerializationPath, expectedMessage, err, ) return } - if seriaErr.PathString() != expectedPath { + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + xmlErr, ok := err.(*ourxmlization.SerializationError) + if !ok || xmlErr.Message != expectedMessage { t.Fatalf( - "Expected the serialization to fail at %s, "+ - "but it failed at %s: %s", - expectedPath, seriaErr.PathString(), seriaErr.Message, + "Expected the XML serialization to fail with: %s, but got: %v", + expectedMessage, err, ) + return + } +} + +// 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( + t *testing.T, + that ourtypes.IClass, +) { + ourverification.Verify( + that, + func(err *ourverification.VerificationError) (abort bool) { + if strings.Contains(err.Message, "safe integer range") { + t.Fatalf("Expected no safe-integer error, but got: %v", err) + } + return + }, + ) + + _, err := ourjsonization.ToJsonable(that) + if err != nil { + t.Fatalf("Expected no JSON serialization error, but got: %v", err) + return + } + + var builder strings.Builder + err = ourxmlization.Marshal(xml.NewEncoder(&builder), that, true) + if err != nil { + t.Fatalf("Expected no XML serialization error, but got: %v", err) + return } } @@ -491,7 +556,7 @@ func TestWrapperDeserializationFail(t *testing.T) { } } -func TestSpecialItemSerializationFailOnOutOfRangeLevel(t *testing.T) { +func TestSpecialItemRefusesUnsafeIntegerInLevel(t *testing.T) { pth := mustFirstExpectedPath(t, "SpecialItem") for _, value := range []int64{ @@ -509,11 +574,39 @@ func TestSpecialItemSerializationFailOnOutOfRangeLevel(t *testing.T) { instance.SetLevel(value) - assertSerializationFailsAt( + assertSafeIntegerRefused( t, instance, + "Level", "Level()", + fmt.Sprintf( + "Expected an integer, but got %d, "+ + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + value, + ), + ) + } +} + +func TestSpecialItemAcceptsSafeIntegerInLevel(t *testing.T) { + pth := mustFirstExpectedPath(t, "SpecialItem") + + for _, value := range []int64{ + 9007199254740991, + -9007199254740991, + } { + jsonable := ourtesting.MustReadJsonable(pth) + + instance, deseriaErr := ourjsonization.SpecialItemFromJsonable( + jsonable, ) + if !assertNoDeserializationError(t, deseriaErr, pth) { + return + } + + instance.SetLevel(value) + + assertSafeIntegerAccepted(t, instance) } } diff --git a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/verification/verification.go b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/verification/verification.go index 8da690bd3..177b1c15d 100644 --- a/dev/test_data/main/golang/expected/variance_of_containers/expected_output/verification/verification.go +++ b/dev/test_data/main/golang/expected/variance_of_containers/expected_output/verification/verification.go @@ -362,6 +362,22 @@ func LevelsArePositive( ) } +// Verify that `that` integer lies in the safe integer range of JSON, as +// checked by [ourcommon.IsSafeInteger] in the serialization as well. +func verifyInt64( + that int64, + onError func(*VerificationError) bool, +) (abort bool) { + if !ourcommon.IsSafeInteger(that) { + abort = onError(newVerificationError(ourcommon.UnsafeIntegerMessage(that))) + if abort { + return + } + } + + return +} + // Verify `that` instance of [ourtypes.ISomeItem]. // // You have to supply the callback `onError` to iterate over the errors. @@ -391,7 +407,20 @@ func VerifySpecialItem( ) (abort bool) { abort = false - // No verification has been defined for ISpecialItem. + abort = verifyInt64( + that.Level(), + func(err *VerificationError) bool { + err.Path.PrependName( + &ourreporting.NameSegment{ + Name: "Level", + }, + ) + return onError(err) + }, + ) + if abort { + return + } return } diff --git a/dev/test_data/main/golang/expected/visibility/expected_output/internal/xmlcommon/xmlcommon.go b/dev/test_data/main/golang/expected/visibility/expected_output/internal/xmlcommon/xmlcommon.go index 436dbd116..363086155 100644 --- a/dev/test_data/main/golang/expected/visibility/expected_output/internal/xmlcommon/xmlcommon.go +++ b/dev/test_data/main/golang/expected/visibility/expected_output/internal/xmlcommon/xmlcommon.go @@ -435,40 +435,6 @@ func ReadTextAs_bool( return } -// Consume the text tokens (char data) as a representation of a `xs:long`. -// -// Any comment tokens are skipped. -// -// The resulting `next` token points to the first token which is neither text -// nor comment. -// -// If we reached the end-of-file, `next` is an [Eof] sentinel token. -func ReadTextAs_long( - decoder *xml.Decoder, - current xml.Token, -) (value int64, next xml.Token, err error) { - var text string - text, next, err = ReadText(decoder, current) - if err != nil { - return - } - text = CollapseWhitespace(text) - - var parseErr error - value, parseErr = strconv.ParseInt(text, 10, 64) - if parseErr != nil { - err = NewDeserializationError( - fmt.Sprintf( - "Expected a value as xs:long, but it could not be parsed: %s: %s", - parseErr.Error(), text, - ), - ) - return - } - - return -} - func constructXsDoubleRe() *regexp.Regexp { // NOTE: // "+INF" is matched although it is written as "INF": XSD 1.1 admits it, @@ -962,18 +928,6 @@ func WriteAsText_bool( return } -// Write the `value` as a `xs:long` in a text element. -// -// Do not flush. -func WriteAsText_long( - encoder *xml.Encoder, - value int64, -) (err error) { - text := strconv.FormatInt(value, 10) - err = WriteText(encoder, text) - return -} - // Write the `value` as a `xs:double` in a text element. // // Do not flush. diff --git a/dev/test_data/main/golang/expected/visibility/expected_output/jsonization/jsonization.go b/dev/test_data/main/golang/expected/visibility/expected_output/jsonization/jsonization.go index 95b0cb4aa..0e5415c5f 100644 --- a/dev/test_data/main/golang/expected/visibility/expected_output/jsonization/jsonization.go +++ b/dev/test_data/main/golang/expected/visibility/expected_output/jsonization/jsonization.go @@ -126,66 +126,6 @@ func boolFromJsonable( return } -// Parse `jsonable` as a 64-bit integer, or return an error. -func int64FromJsonable( - jsonable interface{}, -) (result int64, err error) { - if jsonable == nil { - err = newDeserializationError( - "Expected an integer number, but got null", - ) - return - } - - f, ok := jsonable.(float64) - if !ok { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got %T", - jsonable, - ), - ) - return - } - - if math.IsNaN(f) { - err = newDeserializationError( - "Expected an integer number, but got a NaN", - ) - return - } - - if math.IsInf(f, 0) { - err = newDeserializationError( - "Expected an integer number, but got an infinity", - ) - return - } - - if f != math.Trunc(f) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number, but got a non-integer: %v", - f, - ), - ) - return - } - - result = int64(f) - if f != float64(result) { - err = newDeserializationError( - fmt.Sprintf( - "Expected an integer number fitting into int64, but got: %v", - jsonable, - ), - ) - return - } - - return -} - // Parse `jsonable` as a 64-bit float, or return an error. func float64FromJsonable( jsonable interface{}, @@ -616,30 +556,6 @@ func mustSerializationError(err error) *SerializationError { return seriaErr } -// Try to cast `that` to a float64 and box it as a JSON-able value, or -// return an error. -// -// The result is returned as `interface{}`, not the more specific -// `float64`, so that this function itself can be passed on as a bare -// reference wherever a `func(int64) (interface{}, error)` is expected, -// e.g. as an item (de)serializer in a list or a tuple. -func int64ToJsonable( - that int64, -) (result interface{}, err error) { - if that > 9007199254740991 || that < -9007199254740991 { - err = newSerializationError( - fmt.Sprintf( - "64-bit integer can not be represented as 64-bit float in JSON: %v", - that, - ), - ) - return - } - - result = float64(that); - return -} - // Box `that` as a JSON-able value, or return an error. // // JSON knows neither an infinity nor a not-a-number, so we refuse to diff --git a/dev/test_data/main/java/expected/aas_core_meta.v3/expected_output/src/main/java/aas_core/aas3_0/jsonization/Jsonization.java b/dev/test_data/main/java/expected/aas_core_meta.v3/expected_output/src/main/java/aas_core/aas3_0/jsonization/Jsonization.java index 32155d805..4654296f8 100644 --- a/dev/test_data/main/java/expected/aas_core_meta.v3/expected_output/src/main/java/aas_core/aas3_0/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/aas_core_meta.v3/expected_output/src/main/java/aas_core/aas3_0/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/aas_core_meta.v3/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/aas_core_meta.v3/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ddc796465..38306011d 100644 --- a/dev/test_data/main/java/expected/aas_core_meta.v3/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/aas_core_meta.v3/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package aas_core.aas3_0.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 aas_core.aas3_0.jsonization.Jsonization; @@ -13,19 +16,29 @@ import aas_core.aas3_0.types.impl.*; import aas_core.aas3_0.types.model.IClass; import aas_core.aas3_0.verification.Verification; +import aas_core.aas3_0.xmlcommon.XmlCommon; +import aas_core.aas3_0.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/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 4f037f678..76801025e 100644 --- a/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -1212,18 +1253,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/verification/Verification.java index ca62df26a..f6e6aca67 100644 --- a/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/verification/Verification.java @@ -396,6 +396,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformItem( @@ -412,7 +477,14 @@ public Stream transformChildA( IChildA that) { Stream errorStream = Stream.empty(); - // No verification has been defined for ChildA. + errorStream = Stream.concat(errorStream, + Stream.of(that.getAOnly()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("aOnly")); + return error; + })); return errorStream; } @@ -422,7 +494,14 @@ public Stream transformChildB( IChildB that) { Stream errorStream = Stream.empty(); - // No verification has been defined for ChildB. + errorStream = Stream.concat(errorStream, + Stream.of(that.getBOnly()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("bOnly")); + return error; + })); return errorStream; } @@ -594,6 +673,15 @@ public Stream transformSomething( "The first parent as Child_a must have a small a_only"))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("number")); + return error; + })); + errorStream = Stream.concat(errorStream, Stream.of(that.getKind()) .flatMap(Verification::verifyKind) diff --git a/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 4275d0df4..a62c73c7c 100644 --- a/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -389,11 +390,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result readTextAs_bool( @@ -1592,6 +1600,26 @@ private static void writeByteArrayContent( Base64.getEncoder().encodeToString(that)); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + /** * Write the text of {@code that} as XML content. * @@ -1687,7 +1715,7 @@ private static void writeChildAAsSequence( "getAOnly()", that.getAOnly(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override @@ -1717,7 +1745,7 @@ private static void writeChildBAsSequence( "getBOnly()", that.getBOnly(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override @@ -1747,7 +1775,7 @@ private static void writeSomethingAsSequence( "getNumber()", that.getNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "flag", diff --git a/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index bb636138e..fb4e544a6 100644 --- a/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/annotated_assignments/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -495,6 +508,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testChildAAOnlySerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -518,6 +610,42 @@ public void testChildAAOnlySerializationOutofrange() throws IOException { } } // public void testChildAAOnlySerializationOutofrange + @Test + public void testChildAAOnlyOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final ChildA instance = + Jsonization.Deserialize.deserializeChildA( + loadTheFirstExpected("ChildA")); + + instance.setAOnly(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("aOnly: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testChildAAOnlyOutsideTheSafeIntegerRange + + @Test + public void testChildAAOnlyAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final ChildA instance = + Jsonization.Deserialize.deserializeChildA( + loadTheFirstExpected("ChildA")); + + instance.setAOnly(value); + + assertNoSafeIntegerError(instance); + } + } // public void testChildAAOnlyAtTheSafeIntegerBounds + @Test public void testChildBBOnlySerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -541,6 +669,42 @@ public void testChildBBOnlySerializationOutofrange() throws IOException { } } // public void testChildBBOnlySerializationOutofrange + @Test + public void testChildBBOnlyOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final ChildB instance = + Jsonization.Deserialize.deserializeChildB( + loadTheFirstExpected("ChildB")); + + instance.setBOnly(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("bOnly: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testChildBBOnlyOutsideTheSafeIntegerRange + + @Test + public void testChildBBOnlyAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final ChildB instance = + Jsonization.Deserialize.deserializeChildB( + loadTheFirstExpected("ChildB")); + + instance.setBOnly(value); + + assertNoSafeIntegerError(instance); + } + } // public void testChildBBOnlyAtTheSafeIntegerBounds + @Test public void testSomethingNumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -563,6 +727,42 @@ public void testSomethingNumberSerializationOutofrange() throws IOException { } } } // public void testSomethingNumberSerializationOutofrange + + @Test + public void testSomethingNumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("number: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingNumberOutsideTheSafeIntegerRange + + @Test + public void testSomethingNumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingNumberAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 8cfeb4fbe..25aef5c67 100644 --- a/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -15,6 +15,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -78,15 +80,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -550,18 +591,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/verification/Verification.java index 8a9e95b82..b52ba2c72 100644 --- a/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/verification/Verification.java @@ -93,6 +93,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomething( @@ -208,6 +273,71 @@ public Stream transformSomething( return error; })); + errorStream = Stream.concat(errorStream, + Stream.of(that.getOffset()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("offset")); + return error; + })); + + errorStream = Stream.concat(errorStream, + Stream.of(that.getSmall()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("small")); + return error; + })); + + errorStream = Stream.concat(errorStream, + Stream.of(that.getByNegative()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("byNegative")); + return error; + })); + + errorStream = Stream.concat(errorStream, + Stream.of(that.getNegative()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("negative")); + return error; + })); + + errorStream = Stream.concat(errorStream, + Stream.of(that.getDeviation()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("deviation")); + return error; + })); + + errorStream = Stream.concat(errorStream, + Stream.of(that.getAlignment()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("alignment")); + return error; + })); + + if (that.getOptionalNumber().isPresent()) { + errorStream = Stream.concat(errorStream, + Stream.of(that.getOptionalNumber().get()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("optionalNumber")); + return error; + })); + } + return errorStream; } } @@ -263,7 +393,7 @@ public static Iterable verify(IClass that) { */ public static Stream verifyEvenInt ( Long that) { - Stream errorStream = Stream.empty(); + Stream errorStream = verify_long(that); if (!((Math.floorMod(that, 2L)) == (0))) { errorStream = Stream.concat(errorStream, diff --git a/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/xmlization/Xmlization.java index be3b69ef2..afb7c8d55 100644 --- a/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/arithmetic/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -224,11 +225,18 @@ private static Reporting.Result missingRequiredProperty( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result readTextAs_string( @@ -671,6 +679,26 @@ private static void writeOptionalProperty( } } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeSomethingAsSequence( ISomething that, XMLStreamWriter writer) { @@ -679,28 +707,28 @@ private static void writeSomethingAsSequence( "getEven()", that.getEven(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "offset", "getOffset()", that.getOffset(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "small", "getSmall()", that.getSmall(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "byNegative", "getByNegative()", that.getByNegative(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "text", @@ -714,14 +742,14 @@ private static void writeSomethingAsSequence( "getNegative()", that.getNegative(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "deviation", "getDeviation()", that.getDeviation(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "ratio", @@ -735,14 +763,14 @@ private static void writeSomethingAsSequence( "getAlignment()", that.getAlignment(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeOptionalProperty( "optionalNumber", "getOptionalNumber()", that.getOptionalNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeOptionalProperty( "optionalRatio", diff --git a/dev/test_data/main/java/expected/arithmetic/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/arithmetic/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 23cb5f993..13f10c47e 100644 --- a/dev/test_data/main/java/expected/arithmetic/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/arithmetic/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -198,6 +211,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSomethingEvenSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -221,6 +313,42 @@ public void testSomethingEvenSerializationOutofrange() throws IOException { } } // public void testSomethingEvenSerializationOutofrange + @Test + public void testSomethingEvenOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setEven(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("even: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingEvenOutsideTheSafeIntegerRange + + @Test + public void testSomethingEvenAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setEven(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingEvenAtTheSafeIntegerBounds + @Test public void testSomethingOffsetSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -244,6 +372,42 @@ public void testSomethingOffsetSerializationOutofrange() throws IOException { } } // public void testSomethingOffsetSerializationOutofrange + @Test + public void testSomethingOffsetOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setOffset(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("offset: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingOffsetOutsideTheSafeIntegerRange + + @Test + public void testSomethingOffsetAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setOffset(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingOffsetAtTheSafeIntegerBounds + @Test public void testSomethingSmallSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -267,6 +431,42 @@ public void testSomethingSmallSerializationOutofrange() throws IOException { } } // public void testSomethingSmallSerializationOutofrange + @Test + public void testSomethingSmallOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setSmall(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("small: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingSmallOutsideTheSafeIntegerRange + + @Test + public void testSomethingSmallAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setSmall(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingSmallAtTheSafeIntegerBounds + @Test public void testSomethingByNegativeSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -290,6 +490,42 @@ public void testSomethingByNegativeSerializationOutofrange() throws IOException } } // public void testSomethingByNegativeSerializationOutofrange + @Test + public void testSomethingByNegativeOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setByNegative(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("byNegative: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingByNegativeOutsideTheSafeIntegerRange + + @Test + public void testSomethingByNegativeAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setByNegative(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingByNegativeAtTheSafeIntegerBounds + @Test public void testSomethingNegativeSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -313,6 +549,42 @@ public void testSomethingNegativeSerializationOutofrange() throws IOException { } } // public void testSomethingNegativeSerializationOutofrange + @Test + public void testSomethingNegativeOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNegative(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("negative: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingNegativeOutsideTheSafeIntegerRange + + @Test + public void testSomethingNegativeAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNegative(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingNegativeAtTheSafeIntegerBounds + @Test public void testSomethingDeviationSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -336,6 +608,42 @@ public void testSomethingDeviationSerializationOutofrange() throws IOException { } } // public void testSomethingDeviationSerializationOutofrange + @Test + public void testSomethingDeviationOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setDeviation(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("deviation: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingDeviationOutsideTheSafeIntegerRange + + @Test + public void testSomethingDeviationAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setDeviation(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingDeviationAtTheSafeIntegerBounds + @Test public void testSomethingRatioSerializationNonfinite() throws IOException { for (double value : new double[] {Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NaN}) { @@ -381,6 +689,42 @@ public void testSomethingAlignmentSerializationOutofrange() throws IOException { } } } // public void testSomethingAlignmentSerializationOutofrange + + @Test + public void testSomethingAlignmentOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setAlignment(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("alignment: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingAlignmentOutsideTheSafeIntegerRange + + @Test + public void testSomethingAlignmentAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setAlignment(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingAlignmentAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java index c2c7ae29d..ed579db44 100644 --- a/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -792,18 +833,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/verification/Verification.java index a25049719..8beaf7e9c 100644 --- a/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/verification/Verification.java @@ -128,13 +128,94 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformChildA( IChildA that) { Stream errorStream = Stream.empty(); - // No verification has been defined for ChildA. + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("number")); + return error; + })); + + errorStream = Stream.concat(errorStream, + Stream.of(that.getAOnly()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("aOnly")); + return error; + })); return errorStream; } @@ -144,7 +225,14 @@ public Stream transformChildB( IChildB that) { Stream errorStream = Stream.empty(); - // No verification has been defined for ChildB. + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("number")); + return error; + })); return errorStream; } @@ -201,6 +289,15 @@ public Stream transformSomething( "Number must be between minus one thousand and one thousand"))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("number")); + return error; + })); + errorStream = Stream.concat(errorStream, Stream.of(that.getParent()) .flatMap(Verification::verifyToErrorStream) diff --git a/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 7534f8c5b..69e5eefe3 100644 --- a/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/assert_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -212,11 +213,18 @@ private static Reporting.Result missingRequiredProperty( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result readTextAs_string( @@ -881,6 +889,26 @@ private static void writeClass( NESTED.visit(that, writer); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeListOf_stringified( List that, XMLStreamWriter writer) { @@ -915,14 +943,14 @@ private static void writeChildAAsSequence( "getNumber()", that.getNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "aOnly", "getAOnly()", that.getAOnly(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override @@ -945,7 +973,7 @@ private static void writeChildBAsSequence( "getNumber()", that.getNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override @@ -968,7 +996,7 @@ private static void writeSomethingAsSequence( "getNumber()", that.getNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "text", diff --git a/dev/test_data/main/java/expected/assert_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/assert_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index b292ca968..c31209148 100644 --- a/dev/test_data/main/java/expected/assert_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/assert_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -396,6 +409,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testChildANumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -419,6 +511,42 @@ public void testChildANumberSerializationOutofrange() throws IOException { } } // public void testChildANumberSerializationOutofrange + @Test + public void testChildANumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final ChildA instance = + Jsonization.Deserialize.deserializeChildA( + loadTheFirstExpected("ChildA")); + + instance.setNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("number: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testChildANumberOutsideTheSafeIntegerRange + + @Test + public void testChildANumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final ChildA instance = + Jsonization.Deserialize.deserializeChildA( + loadTheFirstExpected("ChildA")); + + instance.setNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testChildANumberAtTheSafeIntegerBounds + @Test public void testChildAAOnlySerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -442,6 +570,42 @@ public void testChildAAOnlySerializationOutofrange() throws IOException { } } // public void testChildAAOnlySerializationOutofrange + @Test + public void testChildAAOnlyOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final ChildA instance = + Jsonization.Deserialize.deserializeChildA( + loadTheFirstExpected("ChildA")); + + instance.setAOnly(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("aOnly: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testChildAAOnlyOutsideTheSafeIntegerRange + + @Test + public void testChildAAOnlyAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final ChildA instance = + Jsonization.Deserialize.deserializeChildA( + loadTheFirstExpected("ChildA")); + + instance.setAOnly(value); + + assertNoSafeIntegerError(instance); + } + } // public void testChildAAOnlyAtTheSafeIntegerBounds + @Test public void testChildBNumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -465,6 +629,42 @@ public void testChildBNumberSerializationOutofrange() throws IOException { } } // public void testChildBNumberSerializationOutofrange + @Test + public void testChildBNumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final ChildB instance = + Jsonization.Deserialize.deserializeChildB( + loadTheFirstExpected("ChildB")); + + instance.setNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("number: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testChildBNumberOutsideTheSafeIntegerRange + + @Test + public void testChildBNumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final ChildB instance = + Jsonization.Deserialize.deserializeChildB( + loadTheFirstExpected("ChildB")); + + instance.setNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testChildBNumberAtTheSafeIntegerBounds + @Test public void testSomethingNumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -487,6 +687,42 @@ public void testSomethingNumberSerializationOutofrange() throws IOException { } } } // public void testSomethingNumberSerializationOutofrange + + @Test + public void testSomethingNumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("number: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingNumberOutsideTheSafeIntegerRange + + @Test + public void testSomethingNumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingNumberAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/assert_statements/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java b/dev/test_data/main/java/expected/assert_statements/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java index ee0428a6d..fe1224a38 100644 --- a/dev/test_data/main/java/expected/assert_statements/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/assert_statements/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java @@ -544,6 +544,72 @@ public void testDuplicatePropertyFails() throws IOException, XMLStreamException + " is given twice, but got none"); } } // public void testDuplicatePropertyFails + + /** + * Read the first recorded example of ChildA with the content of + * the element {@code xmlName} replaced by {@code text}. + */ + private static ChildA readWith(String xmlName, String text) + throws IOException, XMLStreamException { + final Path searchPath = + Paths.get(Common.TEST_DATA_DIR, "Xml", "Expected", "childA"); + final List paths = Common.findPaths(searchPath, ".xml"); + + if (paths.isEmpty()) { + fail( + "Expected at least one recorded example of childA, but got none"); + } + + final String original = + new String(Files.readAllBytes(paths.get(0)), StandardCharsets.UTF_8); + + final int start = original.indexOf("<" + xmlName + ">") + xmlName.length() + 2; + final int end = original.indexOf(""); + + final String patched = + original.substring(0, start) + text + original.substring(end); + + final XMLInputFactory xmlInputFactory = XMLInputFactory.newInstance(); + final XMLEventReader xmlReader = + xmlInputFactory.createXMLEventReader(new StringReader(patched)); + + return Xmlization.Deserialize.deserializeChildA(xmlReader); + } + + @Test + public void testNumberReadFrom9007199254740991() throws IOException, XMLStreamException { + final ChildA instance = + readWith("number", "9007199254740991"); + + assertEquals( + Long.valueOf(9007199254740991L), + instance.getNumber()); + } // public void testNumberReadFrom9007199254740991 + + @Test + public void testNumberReadFromMinus9007199254740991() throws IOException, XMLStreamException { + final ChildA instance = + readWith("number", "-9007199254740991"); + + assertEquals( + Long.valueOf(-9007199254740991L), + instance.getNumber()); + } // public void testNumberReadFromMinus9007199254740991 + + @Test + public void testNumberReadOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (String text : new String[] {"9007199254740992", "-9007199254740992"}) { + try { + readWith("number", text); + fail("Expected the de-serialization of " + text + " to fail, but it succeeded"); + } catch (XmlCommon.DeserializeException exception) { + assertEquals( + "Expected an integer, but got " + text + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception.getReason().orElse(null)); + } + } + } // public void testNumberReadOutsideTheSafeIntegerRange } // class TestXmlizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/assignment_targets/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/assignment_targets/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 3dd8a83cb..c004cf26a 100644 --- a/dev/test_data/main/java/expected/assignment_targets/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/assignment_targets/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/assignment_targets/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/assignment_targets/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 829949422..9abb879e7 100644 --- a/dev/test_data/main/java/expected/assignment_targets/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/assignment_targets/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/collections_in_returns/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/collections_in_returns/expected_output/src/main/java/dummy/jsonization/Jsonization.java index d6bd64101..4ffdce926 100644 --- a/dev/test_data/main/java/expected/collections_in_returns/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/collections_in_returns/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/collections_in_returns/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/collections_in_returns/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index cb4212083..2b32cdb00 100644 --- a/dev/test_data/main/java/expected/collections_in_returns/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/collections_in_returns/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 76e232d75..007bbf8b7 100644 --- a/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -15,6 +15,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -78,15 +80,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -487,18 +528,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/verification/Verification.java index 0045c7bbe..7532a5a49 100644 --- a/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/verification/Verification.java @@ -87,6 +87,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomething( @@ -132,6 +197,15 @@ public Stream transformSomething( "Flag implies the other flag"))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getCount()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("count")); + return error; + })); + return errorStream; } } diff --git a/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/xmlization/Xmlization.java index a3cb74a45..b317078c1 100644 --- a/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/comparisons/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -273,11 +274,18 @@ private static Reporting.Result readTextAs_bool( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result readTextAs_double( @@ -571,6 +579,26 @@ private static void writeProperty( } } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeSomethingAsSequence( ISomething that, XMLStreamWriter writer) { @@ -600,7 +628,7 @@ private static void writeSomethingAsSequence( "getCount()", that.getCount(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "ratio", diff --git a/dev/test_data/main/java/expected/comparisons/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/comparisons/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index e984dc879..73bb83fd0 100644 --- a/dev/test_data/main/java/expected/comparisons/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/comparisons/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -198,6 +211,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSomethingCountSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -221,6 +313,42 @@ public void testSomethingCountSerializationOutofrange() throws IOException { } } // public void testSomethingCountSerializationOutofrange + @Test + public void testSomethingCountOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setCount(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("count: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingCountOutsideTheSafeIntegerRange + + @Test + public void testSomethingCountAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setCount(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingCountAtTheSafeIntegerBounds + @Test public void testSomethingRatioSerializationNonfinite() throws IOException { for (double value : new double[] {Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NaN}) { diff --git a/dev/test_data/main/java/expected/comparisons/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java b/dev/test_data/main/java/expected/comparisons/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java index e6b078451..f2f1ab3b5 100644 --- a/dev/test_data/main/java/expected/comparisons/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/comparisons/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java @@ -450,6 +450,41 @@ public void testRatioReadFromMinusinf() throws IOException, XMLStreamException { instance.getRatio()); } // public void testRatioReadFromMinusinf + @Test + public void testCountReadFrom9007199254740991() throws IOException, XMLStreamException { + final Something instance = + readWith("count", "9007199254740991"); + + assertEquals( + Long.valueOf(9007199254740991L), + instance.getCount()); + } // public void testCountReadFrom9007199254740991 + + @Test + public void testCountReadFromMinus9007199254740991() throws IOException, XMLStreamException { + final Something instance = + readWith("count", "-9007199254740991"); + + assertEquals( + Long.valueOf(-9007199254740991L), + instance.getCount()); + } // public void testCountReadFromMinus9007199254740991 + + @Test + public void testCountReadOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (String text : new String[] {"9007199254740992", "-9007199254740992"}) { + try { + readWith("count", text); + fail("Expected the de-serialization of " + text + " to fail, but it succeeded"); + } catch (XmlCommon.DeserializeException exception) { + assertEquals( + "Expected an integer, but got " + text + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception.getReason().orElse(null)); + } + } + } // public void testCountReadOutsideTheSafeIntegerRange + @Test public void testFlagReadFrom1() throws IOException, XMLStreamException { final Something instance = diff --git a/dev/test_data/main/java/expected/constants/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/constants/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 811158f9f..f6a91b6b2 100644 --- a/dev/test_data/main/java/expected/constants/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/constants/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -78,18 +78,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/constants/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/constants/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..39ae68159 100644 --- a/dev/test_data/main/java/expected/constants/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/constants/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java index d2960f952..815c37909 100644 --- a/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -15,6 +15,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -78,15 +80,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -487,18 +528,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/verification/Verification.java index 766918857..8fa76370e 100644 --- a/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/verification/Verification.java @@ -38,6 +38,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomething( @@ -161,7 +226,7 @@ public static Stream verifyConstrainedbool ( */ public static Stream verifyPositiveint ( Long that) { - Stream errorStream = Stream.empty(); + Stream errorStream = verify_long(that); if (!(that > 0)) { errorStream = Stream.concat(errorStream, diff --git a/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 2c7ddc7a2..eb649c7e6 100644 --- a/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -375,11 +376,18 @@ private static Reporting.Result readTextAs_bool( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result readTextAs_double( @@ -706,6 +714,26 @@ private static void writeByteArrayContent( Base64.getEncoder().encodeToString(that)); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeSomethingAsSequence( ISomething that, XMLStreamWriter writer) { @@ -721,7 +749,7 @@ private static void writeSomethingAsSequence( "getSomeInt()", that.getSomeInt(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "someFloat", diff --git a/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index bf7404bbe..e88315950 100644 --- a/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/constrained_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -198,6 +211,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSomethingSomeIntSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -221,6 +313,42 @@ public void testSomethingSomeIntSerializationOutofrange() throws IOException { } } // public void testSomethingSomeIntSerializationOutofrange + @Test + public void testSomethingSomeIntOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setSomeInt(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("someInt: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingSomeIntOutsideTheSafeIntegerRange + + @Test + public void testSomethingSomeIntAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setSomeInt(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingSomeIntAtTheSafeIntegerBounds + @Test public void testSomethingSomeFloatSerializationNonfinite() throws IOException { for (double value : new double[] {Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NaN}) { diff --git a/dev/test_data/main/java/expected/custom_serialization_names/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/custom_serialization_names/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 180e10f0e..0ab4e1304 100644 --- a/dev/test_data/main/java/expected/custom_serialization_names/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/custom_serialization_names/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -77,18 +77,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/custom_serialization_names/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/custom_serialization_names/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index db86599f7..98c26e9d1 100644 --- a/dev/test_data/main/java/expected/custom_serialization_names/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/custom_serialization_names/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/jsonization/Jsonization.java index a9f33e6e3..9d326e550 100644 --- a/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -15,6 +15,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -78,15 +80,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -1106,18 +1147,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } @Override diff --git a/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/verification/Verification.java index 027e0c46d..d1994fc3b 100644 --- a/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/verification/Verification.java @@ -38,6 +38,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformBranch( @@ -54,7 +119,14 @@ public Stream transformLeaf( ILeaf that) { Stream errorStream = Stream.empty(); - // No verification has been defined for Leaf. + errorStream = Stream.concat(errorStream, + Stream.of(that.getValue()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("value")); + return error; + })); return errorStream; } @@ -64,7 +136,14 @@ public Stream transformBlossom( IBlossom that) { Stream errorStream = Stream.empty(); - // No verification has been defined for Blossom. + errorStream = Stream.concat(errorStream, + Stream.of(that.getValue()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("value")); + return error; + })); return errorStream; } diff --git a/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 80fa5422c..7f05998be 100644 --- a/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -183,11 +184,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } /** @@ -1342,6 +1350,26 @@ private static void writeClass( NESTED.visit(that, writer); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeBranchAsSequence( IBranch that, XMLStreamWriter writer) { @@ -1394,7 +1422,7 @@ private static void writeLeafAsSequence( "getValue()", that.getValue(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override @@ -1431,7 +1459,7 @@ private static void writeBlossomAsSequence( "getValue()", that.getValue(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "details", diff --git a/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index bad030bc2..f4bc39029 100644 --- a/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/deep_class_hierarchy/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -693,6 +706,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testLeafValueSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -716,6 +808,42 @@ public void testLeafValueSerializationOutofrange() throws IOException { } } // public void testLeafValueSerializationOutofrange + @Test + public void testLeafValueOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Leaf instance = + Jsonization.Deserialize.deserializeLeaf( + loadTheFirstExpected("Leaf")); + + instance.setValue(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("value: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testLeafValueOutsideTheSafeIntegerRange + + @Test + public void testLeafValueAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Leaf instance = + Jsonization.Deserialize.deserializeLeaf( + loadTheFirstExpected("Leaf")); + + instance.setValue(value); + + assertNoSafeIntegerError(instance); + } + } // public void testLeafValueAtTheSafeIntegerBounds + @Test public void testBlossomValueSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -738,6 +866,42 @@ public void testBlossomValueSerializationOutofrange() throws IOException { } } } // public void testBlossomValueSerializationOutofrange + + @Test + public void testBlossomValueOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Blossom instance = + Jsonization.Deserialize.deserializeBlossom( + loadTheFirstExpected("Blossom")); + + instance.setValue(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("value: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testBlossomValueOutsideTheSafeIntegerRange + + @Test + public void testBlossomValueAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Blossom instance = + Jsonization.Deserialize.deserializeBlossom( + loadTheFirstExpected("Blossom")); + + instance.setValue(value); + + assertNoSafeIntegerError(instance); + } + } // public void testBlossomValueAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/empty_class/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/empty_class/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 5066829a3..10ac61218 100644 --- a/dev/test_data/main/java/expected/empty_class/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/empty_class/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -77,18 +77,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/empty_class/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/empty_class/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..39ae68159 100644 --- a/dev/test_data/main/java/expected/empty_class/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/empty_class/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/enum/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/enum/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 757823f6c..91f6149c2 100644 --- a/dev/test_data/main/java/expected/enum/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/enum/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -78,18 +78,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/enum/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/enum/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..39ae68159 100644 --- a/dev/test_data/main/java/expected/enum/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/enum/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/float_only/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/float_only/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 47b4855c2..5103523fb 100644 --- a/dev/test_data/main/java/expected/float_only/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/float_only/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/float_only/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/float_only/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 6853e0e98..e9eb212cd 100644 --- a/dev/test_data/main/java/expected/float_only/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/float_only/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 6d7f4bc12..b5b36203c 100644 --- a/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -652,18 +693,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/verification/Verification.java index f32874245..65ff5a367 100644 --- a/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/verification/Verification.java @@ -268,6 +268,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformItem( @@ -386,6 +451,15 @@ public Stream transformSomething( return error; })); + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumbers()) + .flatMap(Verification::verifyListOf_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("numbers")); + return error; + })); + errorStream = Stream.concat(errorStream, Stream.of(that.getItems()) .flatMap(Verification::verifyListOf_IItem) @@ -458,6 +532,23 @@ public static Stream verifyKind( } } + /** + * Verify the items of {@code that} recursively. + */ + private static Stream verifyListOf_long( + List that) { + return Verification.zip( + IntStream.iterate(0, i -> i + 1).boxed(), + that.stream()) + .flatMap(itemTuple -> + Verification.verify_long(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); + } + /** * Verify the items of {@code that} recursively. */ diff --git a/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java index cf50ab52a..c3e226037 100644 --- a/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/for_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -262,11 +263,18 @@ private static Reporting.Result readTextAs_Kind( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result> readListOf_long( @@ -700,6 +708,26 @@ private static void writeClass( NESTED.visit(that, writer); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + /** * Write the text of {@code that} as XML content. * @@ -731,6 +759,22 @@ private static void writeListOf_stringified( } } + private static void writeListOf_long( + List that, + XMLStreamWriter writer) { + int index = 0; + try { + for (Long item : that) { + writeAtV_long(item, writer); + index++; + } + } catch (XmlCommon.SerializeFailure failure) { + failure.getError().prependSegment( + new Reporting.IndexSegment(index)); + throw failure; + } + } + private static void writeListOf_IClass( List that, XMLStreamWriter writer) { @@ -757,6 +801,16 @@ private static void writeAtV_stringified( XmlCommon::writeStringifiedContent); } + private static void writeAtV_long( + Long that, + XMLStreamWriter writer) { + XmlCommon.writeElement( + "v", + that, + writer, + _VisitorWithWriter::writeLongContent); + } + private static void writeItemAsSequence( IItem that, XMLStreamWriter writer) { @@ -795,7 +849,7 @@ private static void writeSomethingAsSequence( "getNumbers()", that.getNumbers(), writer, - _VisitorWithWriter::writeListOf_stringified); + _VisitorWithWriter::writeListOf_long); writeProperty( "texts", diff --git a/dev/test_data/main/java/expected/for_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/for_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 7043834f2..dab10376e 100644 --- a/dev/test_data/main/java/expected/for_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/for_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -297,6 +310,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSomethingNumbersSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -319,6 +411,42 @@ public void testSomethingNumbersSerializationOutofrange() throws IOException { } } } // public void testSomethingNumbersSerializationOutofrange + + @Test + public void testSomethingNumbersOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumbers(Arrays.asList(0L, value)); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("numbers[1]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingNumbersOutsideTheSafeIntegerRange + + @Test + public void testSomethingNumbersAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumbers(Arrays.asList(0L, value)); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingNumbersAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 0931950af..b9c4246ba 100644 --- a/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -1041,18 +1082,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/verification/Verification.java index 6c63c6044..f1992bec7 100644 --- a/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/verification/Verification.java @@ -352,6 +352,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformItem( @@ -368,7 +433,14 @@ public Stream transformChildA( IChildA that) { Stream errorStream = Stream.empty(); - // No verification has been defined for ChildA. + errorStream = Stream.concat(errorStream, + Stream.of(that.getAOnly()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("aOnly")); + return error; + })); return errorStream; } @@ -378,7 +450,14 @@ public Stream transformChildB( IChildB that) { Stream errorStream = Stream.empty(); - // No verification has been defined for ChildB. + errorStream = Stream.concat(errorStream, + Stream.of(that.getBOnly()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("bOnly")); + return error; + })); return errorStream; } @@ -555,6 +634,15 @@ public Stream transformSomething( return error; })); + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("number")); + return error; + })); + errorStream = Stream.concat(errorStream, Stream.of(that.getItem()) .flatMap(Verification::verifyToErrorStream) diff --git a/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java index bf010439f..8c32b48ac 100644 --- a/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/if_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -277,11 +278,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result> readListOf_IParent( @@ -1261,6 +1269,26 @@ private static void writeClass( NESTED.visit(that, writer); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + /** * Write the text of {@code that} as XML content. * @@ -1337,7 +1365,7 @@ private static void writeChildAAsSequence( "getAOnly()", that.getAOnly(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override @@ -1367,7 +1395,7 @@ private static void writeChildBAsSequence( "getBOnly()", that.getBOnly(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override @@ -1434,7 +1462,7 @@ private static void writeSomethingAsSequence( "getNumber()", that.getNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "flag", diff --git a/dev/test_data/main/java/expected/if_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/if_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 7aecda73c..284030f2f 100644 --- a/dev/test_data/main/java/expected/if_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/if_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -594,6 +607,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testChildAAOnlySerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -617,6 +709,42 @@ public void testChildAAOnlySerializationOutofrange() throws IOException { } } // public void testChildAAOnlySerializationOutofrange + @Test + public void testChildAAOnlyOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final ChildA instance = + Jsonization.Deserialize.deserializeChildA( + loadTheFirstExpected("ChildA")); + + instance.setAOnly(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("aOnly: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testChildAAOnlyOutsideTheSafeIntegerRange + + @Test + public void testChildAAOnlyAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final ChildA instance = + Jsonization.Deserialize.deserializeChildA( + loadTheFirstExpected("ChildA")); + + instance.setAOnly(value); + + assertNoSafeIntegerError(instance); + } + } // public void testChildAAOnlyAtTheSafeIntegerBounds + @Test public void testChildBBOnlySerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -640,6 +768,42 @@ public void testChildBBOnlySerializationOutofrange() throws IOException { } } // public void testChildBBOnlySerializationOutofrange + @Test + public void testChildBBOnlyOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final ChildB instance = + Jsonization.Deserialize.deserializeChildB( + loadTheFirstExpected("ChildB")); + + instance.setBOnly(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("bOnly: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testChildBBOnlyOutsideTheSafeIntegerRange + + @Test + public void testChildBBOnlyAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final ChildB instance = + Jsonization.Deserialize.deserializeChildB( + loadTheFirstExpected("ChildB")); + + instance.setBOnly(value); + + assertNoSafeIntegerError(instance); + } + } // public void testChildBBOnlyAtTheSafeIntegerBounds + @Test public void testSomethingNumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -662,6 +826,42 @@ public void testSomethingNumberSerializationOutofrange() throws IOException { } } } // public void testSomethingNumberSerializationOutofrange + + @Test + public void testSomethingNumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("number: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingNumberOutsideTheSafeIntegerRange + + @Test + public void testSomethingNumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingNumberAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/implementation_specific/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/implementation_specific/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 80c7f842f..89ee29072 100644 --- a/dev/test_data/main/java/expected/implementation_specific/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/implementation_specific/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/implementation_specific/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/implementation_specific/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 429834901..66ea6b36c 100644 --- a/dev/test_data/main/java/expected/implementation_specific/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/implementation_specific/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/jsonization/Jsonization.java index fe6ef1ce3..b13b45f4b 100644 --- a/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -525,18 +566,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/verification/Verification.java index 4675a2eb2..c756236b9 100644 --- a/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/verification/Verification.java @@ -73,10 +73,21 @@ public static Boolean noNumberIsSeven( /** * Check the ranges over the integers with a length as a bound. + * + *

The {@code 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 + * {@code 2 ** 53}. A negative {@code start} is refused, while a {@code start} beyond + * the numbers leaves nothing to check. */ public static Boolean noSevenFrom( List numbers, Long start) { + if (start < 0) { + return false; + } + if (start >= numbers.size()) { + return true; + } for (long i = start; i < numbers.size(); i++) { long number = numbers.get(Math.toIntExact(i)); if (number == 7) { @@ -137,6 +148,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomething( @@ -205,6 +281,35 @@ public Stream transformSomething( "No number is seven"))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumbers()) + .flatMap(Verification::verifyListOf_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("numbers")); + return error; + })); + + errorStream = Stream.concat(errorStream, + Stream.of(that.getCount()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("count")); + return error; + })); + + if (that.getMaybeCount().isPresent()) { + errorStream = Stream.concat(errorStream, + Stream.of(that.getMaybeCount().get()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("maybeCount")); + return error; + })); + } + return errorStream; } } @@ -255,6 +360,23 @@ public static Iterable verify(IClass that) { return new _ValidationErrorIterable(that); } + /** + * Verify the items of {@code that} recursively. + */ + private static Stream verifyListOf_long( + List that) { + return Verification.zip( + IntStream.iterate(0, i -> i + 1).boxed(), + that.stream()) + .flatMap(itemTuple -> + Verification.verify_long(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); + } + private static class _Pair { private final A first; private final B second; diff --git a/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/xmlization/Xmlization.java index b8394e51a..122c329ea 100644 --- a/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -212,11 +213,18 @@ private static Reporting.Result missingRequiredProperty( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result> readListOf_long( @@ -535,6 +543,42 @@ private static void writeOptionalProperty( } } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + + private static void writeListOf_long( + List that, + XMLStreamWriter writer) { + int index = 0; + try { + for (Long item : that) { + writeAtV_long(item, writer); + index++; + } + } catch (XmlCommon.SerializeFailure failure) { + failure.getError().prependSegment( + new Reporting.IndexSegment(index)); + throw failure; + } + } + private static void writeListOf_stringified( List that, XMLStreamWriter writer) { @@ -551,6 +595,16 @@ private static void writeListOf_stringified( } } + private static void writeAtV_long( + Long that, + XMLStreamWriter writer) { + XmlCommon.writeElement( + "v", + that, + writer, + _VisitorWithWriter::writeLongContent); + } + private static void writeAtV_stringified( Object that, XMLStreamWriter writer) { @@ -569,7 +623,7 @@ private static void writeSomethingAsSequence( "getNumbers()", that.getNumbers(), writer, - _VisitorWithWriter::writeListOf_stringified); + _VisitorWithWriter::writeListOf_long); writeProperty( "texts", @@ -583,14 +637,14 @@ private static void writeSomethingAsSequence( "getCount()", that.getCount(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeOptionalProperty( "maybeCount", "getMaybeCount()", that.getMaybeCount(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override diff --git a/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 662c5d6ba..4ae8e97f5 100644 --- a/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/int_length_interplay/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -198,6 +211,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSomethingNumbersSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -221,6 +313,42 @@ public void testSomethingNumbersSerializationOutofrange() throws IOException { } } // public void testSomethingNumbersSerializationOutofrange + @Test + public void testSomethingNumbersOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumbers(Arrays.asList(0L, value)); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("numbers[1]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingNumbersOutsideTheSafeIntegerRange + + @Test + public void testSomethingNumbersAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumbers(Arrays.asList(0L, value)); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingNumbersAtTheSafeIntegerBounds + @Test public void testSomethingCountSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -243,6 +371,42 @@ public void testSomethingCountSerializationOutofrange() throws IOException { } } } // public void testSomethingCountSerializationOutofrange + + @Test + public void testSomethingCountOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setCount(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("count: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingCountOutsideTheSafeIntegerRange + + @Test + public void testSomethingCountAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setCount(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingCountAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/common/StringHelpers.java b/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/common/StringHelpers.java index ea6418fbc..45f15bf74 100644 --- a/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/common/StringHelpers.java +++ b/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/common/StringHelpers.java @@ -5,6 +5,8 @@ package dummy.common; +import dummy.verification.Verification; + /** * Provide string operations which follow the Python implementation, since * Python is the language of the meta-model specifications. @@ -149,8 +151,12 @@ public static String lstrip(String text, String chars) { * 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. @@ -160,9 +166,6 @@ public static String lstrip(String text, String chars) { * @throws IllegalArgumentException if {@code text} is not a safe integer */ public static long parseSafeInt(String text) { - // This is 2^53 - 1. - final long maxSafeInteger = 9007199254740991L; - final int length = text.length(); int offset = 0; @@ -188,9 +191,10 @@ public static long parseSafeInt(String text) { } // NOTE (mristin): - // The value can not overflow as we check the range after each digit. + // The value is at most 2^53 - 1 before this step, so it can not + // overflow a long. value = value * 10 + (character - '0'); - if (value > maxSafeInteger) { + if (!Verification.isSafeInteger(value)) { throw new IllegalArgumentException( "Expected a safe integer, but got a text out of its range: " + text); } diff --git a/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 331821a30..6b7bec0cb 100644 --- a/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -77,18 +77,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/verification/Verification.java index 0c0fa1818..1c5d858f7 100644 --- a/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/int_parsing/expected_output/src/main/java/dummy/verification/Verification.java @@ -127,6 +127,50 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomething( diff --git a/dev/test_data/main/java/expected/int_parsing/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/int_parsing/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..5825e22cb 100644 --- a/dev/test_data/main/java/expected/int_parsing/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/int_parsing/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -177,6 +190,85 @@ public void testSomethingVerificationFail() throws IOException { } } } // public void testSomethingVerificationFail + + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/is_instance/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/is_instance/expected_output/src/main/java/dummy/jsonization/Jsonization.java index e70a51dd0..869a464c4 100644 --- a/dev/test_data/main/java/expected/is_instance/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/is_instance/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/is_instance/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/is_instance/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 0f105eb0c..42eb8b6a5 100644 --- a/dev/test_data/main/java/expected/is_instance/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/is_instance/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/jsonization/Jsonization.java index f887ab977..e7986f171 100644 --- a/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -78,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ @@ -187,21 +176,21 @@ private static Reporting.Result missingRequiredProperty(String name) { *

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( - JsonNode node, String path) { + private static Reporting.Result tryJsonValueFrom(JsonNode node) { if (node == null || node.isMissingNode()) { return Reporting.Result.failure(new Reporting.Error( - "Expected a JSON-able value at " + path + ", but got: " + node)); + "Expected a JSON-able value, but got: " + node)); } if (node.isNull()) { @@ -209,15 +198,9 @@ private static Reporting.Result tryJsonValueFromAt( } if (node.isNumber()) { - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number. Jackson's own - // parser never gives us one, but {@code node} may well have been - // put together programmatically. - final double value = node.doubleValue(); - if (Double.isInfinite(value) || Double.isNaN(value)) { - return Reporting.Result.failure(new Reporting.Error( - "Expected a JSON-able value at " + path + ", but got the number " + - value + ", which is neither finite nor representable in JSON")); + final String cause = Verification.checkJsonAbleNumber(node); + if (cause != null) { + return Reporting.Result.failure(new Reporting.Error(cause)); } return Reporting.Result.success(node.deepCopy()); } @@ -230,9 +213,10 @@ private static Reporting.Result tryJsonValueFromAt( final ArrayNode result = JsonNodeFactory.instance.arrayNode(); int index = 0; for (JsonNode item : node) { - final Reporting.Result itemResult = tryJsonValueFromAt( - item, path + "[" + index + "]"); + final Reporting.Result itemResult = tryJsonValueFrom(item); if (itemResult.isError()) { + itemResult.getError().prependSegment( + new Reporting.IndexSegment(index)); return itemResult; } result.add(itemResult.getResult()); @@ -246,9 +230,11 @@ private static Reporting.Result tryJsonValueFromAt( final Iterator names = node.fieldNames(); while (names.hasNext()) { final String name = names.next(); - final Reporting.Result valueResult = tryJsonValueFromAt( - node.get(name), path + "." + name); + final Reporting.Result valueResult = tryJsonValueFrom( + node.get(name)); if (valueResult.isError()) { + valueResult.getError().prependSegment( + new Reporting.KeySegment(name)); return valueResult; } result.set(name, valueResult.getResult()); @@ -258,18 +244,7 @@ private static Reporting.Result tryJsonValueFromAt( return Reporting.Result.failure(new Reporting.Error( "Expected a JSON-able value (a null, a boolean, a number, a string, " + - "an array or an object) at " + path + ", but got: " + - node.getNodeType())); - } - - /** - * 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) { - return tryJsonValueFromAt(node, "$"); + "an array or an object), but got: " + node.getNodeType())); } /** @@ -504,6 +479,84 @@ Reporting.Error getError() { */ public static class Deserialize { + /** + * Deserialize a JSON-able value 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 value + */ + public static JsonNode deserializeJsonValue(JsonNode node) { + final Reporting.Result result = + _DeserializeImplementation.tryJsonValueFrom( + node); + + return result.onError(error -> { + throw new DeserializeException( + Reporting.generateJsonPath(error.getPathSegments()), + error.getCause()); + }); + } + + /** + * Deserialize a JSON-able array 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 array + */ + public static ArrayNode deserializeJsonArray(JsonNode node) { + final Reporting.Result result = + _DeserializeImplementation.tryJsonArrayFrom( + node); + + return result.onError(error -> { + throw new DeserializeException( + Reporting.generateJsonPath(error.getPathSegments()), + error.getCause()); + }); + } + + /** + * Deserialize a JSON-able object 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 object + */ + public static ObjectNode deserializeJsonObject(JsonNode node) { + final Reporting.Result result = + _DeserializeImplementation.tryJsonObjectFrom( + node); + + return result.onError(error -> { + throw new DeserializeException( + Reporting.generateJsonPath(error.getPathSegments()), + error.getCause()); + }); + } + /** * Deserialize an instance of Something from {@code node}. * @@ -605,9 +658,9 @@ private static void setOptionalProperty( * 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 @@ -628,12 +681,9 @@ private static JsonNode jsonValueToJsonNode(JsonNode that) { } if (that.isNumber()) { - final double value = that.doubleValue(); - if (Double.isInfinite(value) || Double.isNaN(value)) { - throw new _SerializeFailure( - new Reporting.Error( - "Expected a JSON-able value, but got the number " + value + - ", which is neither finite nor representable in JSON")); + final String cause = Verification.checkJsonAbleNumber(that); + if (cause != null) { + throw new _SerializeFailure(new Reporting.Error(cause)); } return that.deepCopy(); } @@ -748,6 +798,27 @@ public static JsonNode toJsonObject(IClass that) { error.getCause()); } } + + /** + * 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) { + try { + return _Transformer.jsonValueToJsonNode(that); + } catch (_SerializeFailure failure) { + final Reporting.Error error = failure.getError(); + throw new SerializeException( + Reporting.generateJavaPath(error.getPathSegments()), + error.getCause()); + } + } } } diff --git a/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/verification/Verification.java index ce079009e..5d20ad66e 100644 --- a/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/verification/Verification.java @@ -43,6 +43,50 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + /** * Stream the field names of {@code node}. * @@ -54,75 +98,121 @@ private static Stream streamOfFieldNames(JsonNode node) { return names.stream(); } + /** + * 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) { + return "Expected a JSON-able value, but got the integer " + value + + ", 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) { + switch (node.numberType()) { + case INT: + case LONG: + if (isSafeInteger(node.longValue())) { + return null; + } + break; + + case BIG_INTEGER: + // NOTE (mristin): + // Jackson gives a big integer for every integer if + // ``USE_BIG_INTEGER_FOR_INTS`` is enabled, so a small one can arrive + // as well. + if (isSafeInteger(node.bigIntegerValue())) { + return null; + } + break; + + default: { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number. Jackson's own + // parser never gives us one, but {@code node} may well have been + // put together programmatically. + final double value = node.doubleValue(); + if (Double.isInfinite(value) || Double.isNaN(value)) { + return "Expected a JSON-able value, but got the number " + value + + ", which is neither finite nor representable in JSON"; + } + return null; + } + } + + return jsonAbleIntegerOutsideSafeRangeMessage(node); + } + /** * 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( - JsonNode node, String path) { + public static Stream verifyJsonValue(JsonNode node) { if (node == null || node.isMissingNode()) { return Stream.of( new Reporting.Error( - "Expected a JSON-able value at " + path - + ", but got: " + node)); + "Expected a JSON-able value, but got: " + node)); } - if (node.isNull()) { + if (node.isNull() || node.isBoolean() || node.isTextual()) { return Stream.empty(); } if (node.isNumber()) { - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though a Jackson node holds either. - final double value = node.doubleValue(); - if (Double.isInfinite(value) || Double.isNaN(value)) { - return Stream.of( - new Reporting.Error( - "Expected a JSON-able value at " + path - + ", but got the number " + value - + ", which is neither finite nor representable in JSON")); - } - return Stream.empty(); - } - - if (node.isBoolean() || node.isTextual()) { - return Stream.empty(); + final String cause = checkJsonAbleNumber(node); + return (cause == null) + ? Stream.empty() + : Stream.of(new Reporting.Error(cause)); } if (node.isArray()) { return Verification.zip( IntStream.iterate(0, i -> i + 1).boxed(), StreamSupport.stream(node.spliterator(), false)) - .flatMap(elemTuple -> verifyJsonValueAt( - elemTuple.getSecond(), - path + "[" + elemTuple.getFirst() + "]")); + .flatMap(itemTuple -> verifyJsonValue(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); } if (node.isObject()) { return streamOfFieldNames(node) - .flatMap(name -> verifyJsonValueAt( - node.get(name), path + "." + name)); + .flatMap(name -> verifyJsonValue(node.get(name)) + .map(error -> { + error.prependSegment(new Reporting.KeySegment(name)); + return error; + })); } return Stream.of( new Reporting.Error( "Expected a JSON-able value (a null, a boolean, a number, a string, " - + "an array or an object) at " + path - + ", but got: " + node.getNodeType())); - } - - /** - * 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) { - return verifyJsonValueAt(node, "$"); + + "an array or an object), but got: " + node.getNodeType())); } /** @@ -309,14 +399,10 @@ private static Stream verifyJsonObjectBy_NonEmptyString( Verification.streamOfFieldNames(that) .flatMap(key -> Verification.verifyNonEmptyString(key) - .map(error -> - // NOTE (mristin): - // A member of an open JSON object is no property of one of - // our classes, so it gets no segment of its own -- the key - // goes into the message instead, as it does in - // ``verifyJsonValue``. - new Reporting.Error( - "In the member \"" + key + "\": " + error.getCause())))); + .map(error -> { + error.prependSegment(new Reporting.KeySegment(key)); + return error; + }))); } private static class _Pair { diff --git a/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java b/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java index d4763b8c0..822fda558 100644 --- a/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java +++ b/dev/test_data/main/java/expected/json_types/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java @@ -10,6 +10,7 @@ import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; +import java.math.BigInteger; import java.util.Iterator; import java.util.regex.Pattern; import com.fasterxml.jackson.databind.JsonNode; @@ -17,6 +18,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import dummy.reporting.Reporting; +import dummy.verification.Verification; import dummy.xmlcommon.XmlCommon; /** @@ -214,17 +216,20 @@ private static Reporting.Result readXmlRpcDouble( // does a JSON document, so reading every double element as a double // node would silently turn the 1 of a document into a 1.0 on the way // through XML, and back again. + // + // Such an integer has to be a safe integer, exactly as in JSON. We read + // it as a big integer first, so that a numeral beyond a long is refused + // rather than truncated. if (text.indexOf('.') < 0 && text.indexOf('e') < 0 && text.indexOf('E') < 0) { - try { - return Reporting.Result.success( - JsonNodeFactory.instance.numberNode(Long.parseLong(text))); - } catch (NumberFormatException exception) { - // NOTE (mristin): - // The numeral does not fit a long, so it stays a double -- which - // is what a JSON parser would give for it as well. + final BigInteger integer = new BigInteger(text); + if (!Verification.isSafeInteger(integer)) { + return Reporting.Result.failure(new Reporting.Error( + Verification.jsonAbleIntegerOutsideSafeRangeMessage(integer))); } + return Reporting.Result.success( + JsonNodeFactory.instance.numberNode(integer.longValue())); } return Reporting.Result.success( @@ -481,15 +486,13 @@ private static void writeValueContent( } if (that.isNumber()) { - final double value = that.doubleValue(); - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, and readXmlRpcDouble refuses to read either back. - if (Double.isInfinite(value) || Double.isNaN(value)) { - throw new XmlCommon.SerializeFailure(new Reporting.Error( - "Expected a JSON-able value, but got the number " + value + - ", which is neither finite nor representable in JSON")); + // We check the number exactly as the verification and the JSON + // de/serialization do, and readXmlRpcDouble refuses to read back + // what we refuse here. + final String cause = Verification.checkJsonAbleNumber(that); + if (cause != null) { + throw new XmlCommon.SerializeFailure(new Reporting.Error(cause)); } XmlCommon.writeElementInNoNamespace( diff --git a/dev/test_data/main/java/expected/json_types/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/json_types/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..870057320 100644 --- a/dev/test_data/main/java/expected/json_types/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/json_types/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -177,6 +190,263 @@ public void testSomethingVerificationFail() throws IOException { } } } // public void testSomethingVerificationFail + + private static JsonNode loadTheFirstExpected(String modelType) throws IOException { + final List paths = + Common.findPaths( + Paths.get( + Common.TEST_DATA_DIR, + "Json", + "Expected", + modelType), + ".json"); + + if (paths.isEmpty()) { + fail("Expected at least one recorded example of " + modelType + ", but got none"); + } + + return CommonJson.readFromFile(paths.get(0)); + } + + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + + @Test + public void testSomethingValueOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final JsonNode node = JsonNodeFactory.instance.arrayNode().add(value); + instance.setValue(node); + + final String expected = + "Expected a JSON-able value, but got the integer " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("value[0]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingValueOutsideTheSafeIntegerRange + + @Test + public void testSomethingValueAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final JsonNode node = JsonNodeFactory.instance.arrayNode().add(value); + instance.setValue(node); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingValueAtTheSafeIntegerBounds + + @Test + public void testSomethingValuesOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final ArrayNode node = JsonNodeFactory.instance.arrayNode().add(value); + instance.setValues(node); + + final String expected = + "Expected a JSON-able value, but got the integer " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("values[0]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingValuesOutsideTheSafeIntegerRange + + @Test + public void testSomethingValuesAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final ArrayNode node = JsonNodeFactory.instance.arrayNode().add(value); + instance.setValues(node); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingValuesAtTheSafeIntegerBounds + + @Test + public void testSomethingMappingOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final ObjectNode node = JsonNodeFactory.instance.objectNode().put("key", value); + instance.setMapping(node); + + final String expected = + "Expected a JSON-able value, but got the integer " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("mapping[\"key\"]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingMappingOutsideTheSafeIntegerRange + + @Test + public void testSomethingMappingAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final ObjectNode node = JsonNodeFactory.instance.objectNode().put("key", value); + instance.setMapping(node); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingMappingAtTheSafeIntegerBounds + + @Test + public void testJsonValueOutsideTheSafeIntegerRangeFails() { + final JsonNode[] nodes = new JsonNode[] { + JsonNodeFactory.instance.numberNode(9007199254740992L), + JsonNodeFactory.instance.numberNode(-9007199254740992L), + JsonNodeFactory.instance.numberNode(BigInteger.ONE.shiftLeft(64)) + }; + + for (JsonNode node : nodes) { + final String expected = + "Expected a JSON-able value, but got the integer " + node + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List got = new ArrayList<>(); + Verification.verifyJsonValue(node).forEach( + error -> got.add(error.getCause())); + assertEquals(Arrays.asList(expected), got); + + try { + Jsonization.Deserialize.deserializeJsonValue(node); + fail("Expected the de-serialization of " + node + " to fail, but it succeeded"); + } catch (Jsonization.DeserializeException exception) { + assertEquals(expected, exception.getReason().orElse(null)); + } + + try { + Jsonization.Serialize.jsonValueToJsonNode(node); + fail("Expected the serialization of " + node + " to fail, but it succeeded"); + } catch (Jsonization.SerializeException exception) { + assertEquals(expected, exception.getReason().orElse(null)); + } + } + } // public void testJsonValueOutsideTheSafeIntegerRangeFails + + @Test + public void testJsonValueAtTheSafeIntegerBoundsOk() { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final JsonNode node = JsonNodeFactory.instance.numberNode(value); + + assertEquals(0L, Verification.verifyJsonValue(node).count()); + + assertEquals(node, Jsonization.Deserialize.deserializeJsonValue(node)); + + assertEquals(node, Jsonization.Serialize.jsonValueToJsonNode(node)); + } + } // public void testJsonValueAtTheSafeIntegerBoundsOk } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 67169bbdd..590dc7cab 100644 --- a/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -79,18 +80,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ @@ -291,21 +280,21 @@ private static Reporting.Result> parseTu *

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( - JsonNode node, String path) { + private static Reporting.Result tryJsonValueFrom(JsonNode node) { if (node == null || node.isMissingNode()) { return Reporting.Result.failure(new Reporting.Error( - "Expected a JSON-able value at " + path + ", but got: " + node)); + "Expected a JSON-able value, but got: " + node)); } if (node.isNull()) { @@ -313,15 +302,9 @@ private static Reporting.Result tryJsonValueFromAt( } if (node.isNumber()) { - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number. Jackson's own - // parser never gives us one, but {@code node} may well have been - // put together programmatically. - final double value = node.doubleValue(); - if (Double.isInfinite(value) || Double.isNaN(value)) { - return Reporting.Result.failure(new Reporting.Error( - "Expected a JSON-able value at " + path + ", but got the number " + - value + ", which is neither finite nor representable in JSON")); + final String cause = Verification.checkJsonAbleNumber(node); + if (cause != null) { + return Reporting.Result.failure(new Reporting.Error(cause)); } return Reporting.Result.success(node.deepCopy()); } @@ -334,9 +317,10 @@ private static Reporting.Result tryJsonValueFromAt( final ArrayNode result = JsonNodeFactory.instance.arrayNode(); int index = 0; for (JsonNode item : node) { - final Reporting.Result itemResult = tryJsonValueFromAt( - item, path + "[" + index + "]"); + final Reporting.Result itemResult = tryJsonValueFrom(item); if (itemResult.isError()) { + itemResult.getError().prependSegment( + new Reporting.IndexSegment(index)); return itemResult; } result.add(itemResult.getResult()); @@ -350,9 +334,11 @@ private static Reporting.Result tryJsonValueFromAt( final Iterator names = node.fieldNames(); while (names.hasNext()) { final String name = names.next(); - final Reporting.Result valueResult = tryJsonValueFromAt( - node.get(name), path + "." + name); + final Reporting.Result valueResult = tryJsonValueFrom( + node.get(name)); if (valueResult.isError()) { + valueResult.getError().prependSegment( + new Reporting.KeySegment(name)); return valueResult; } result.set(name, valueResult.getResult()); @@ -362,18 +348,7 @@ private static Reporting.Result tryJsonValueFromAt( return Reporting.Result.failure(new Reporting.Error( "Expected a JSON-able value (a null, a boolean, a number, a string, " + - "an array or an object) at " + path + ", but got: " + - node.getNodeType())); - } - - /** - * 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) { - return tryJsonValueFromAt(node, "$"); + "an array or an object), but got: " + node.getNodeType())); } /** @@ -603,6 +578,84 @@ Reporting.Error getError() { */ public static class Deserialize { + /** + * Deserialize a JSON-able value 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 value + */ + public static JsonNode deserializeJsonValue(JsonNode node) { + final Reporting.Result result = + _DeserializeImplementation.tryJsonValueFrom( + node); + + return result.onError(error -> { + throw new DeserializeException( + Reporting.generateJsonPath(error.getPathSegments()), + error.getCause()); + }); + } + + /** + * Deserialize a JSON-able array 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 array + */ + public static ArrayNode deserializeJsonArray(JsonNode node) { + final Reporting.Result result = + _DeserializeImplementation.tryJsonArrayFrom( + node); + + return result.onError(error -> { + throw new DeserializeException( + Reporting.generateJsonPath(error.getPathSegments()), + error.getCause()); + }); + } + + /** + * Deserialize a JSON-able object 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 object + */ + public static ObjectNode deserializeJsonObject(JsonNode node) { + final Reporting.Result result = + _DeserializeImplementation.tryJsonObjectFrom( + node); + + return result.onError(error -> { + throw new DeserializeException( + Reporting.generateJsonPath(error.getPathSegments()), + error.getCause()); + }); + } + /** * Deserialize an instance of Something from {@code node}. * @@ -715,9 +768,9 @@ private static JsonNode stringToJsonNode(String that) { * 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 @@ -738,12 +791,9 @@ private static JsonNode jsonValueToJsonNode(JsonNode that) { } if (that.isNumber()) { - final double value = that.doubleValue(); - if (Double.isInfinite(value) || Double.isNaN(value)) { - throw new _SerializeFailure( - new Reporting.Error( - "Expected a JSON-able value, but got the number " + value + - ", which is neither finite nor representable in JSON")); + final String cause = Verification.checkJsonAbleNumber(that); + if (cause != null) { + throw new _SerializeFailure(new Reporting.Error(cause)); } return that.deepCopy(); } @@ -914,6 +964,27 @@ public static JsonNode toJsonObject(IClass that) { error.getCause()); } } + + /** + * 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) { + try { + return _Transformer.jsonValueToJsonNode(that); + } catch (_SerializeFailure failure) { + final Reporting.Error error = failure.getError(); + throw new SerializeException( + Reporting.generateJavaPath(error.getPathSegments()), + error.getCause()); + } + } } } diff --git a/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/verification/Verification.java index 0de25e3f8..5908b3eac 100644 --- a/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/verification/Verification.java @@ -43,6 +43,50 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + /** * Stream the field names of {@code node}. * @@ -54,75 +98,121 @@ private static Stream streamOfFieldNames(JsonNode node) { return names.stream(); } + /** + * 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) { + return "Expected a JSON-able value, but got the integer " + value + + ", 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) { + switch (node.numberType()) { + case INT: + case LONG: + if (isSafeInteger(node.longValue())) { + return null; + } + break; + + case BIG_INTEGER: + // NOTE (mristin): + // Jackson gives a big integer for every integer if + // ``USE_BIG_INTEGER_FOR_INTS`` is enabled, so a small one can arrive + // as well. + if (isSafeInteger(node.bigIntegerValue())) { + return null; + } + break; + + default: { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number. Jackson's own + // parser never gives us one, but {@code node} may well have been + // put together programmatically. + final double value = node.doubleValue(); + if (Double.isInfinite(value) || Double.isNaN(value)) { + return "Expected a JSON-able value, but got the number " + value + + ", which is neither finite nor representable in JSON"; + } + return null; + } + } + + return jsonAbleIntegerOutsideSafeRangeMessage(node); + } + /** * 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( - JsonNode node, String path) { + public static Stream verifyJsonValue(JsonNode node) { if (node == null || node.isMissingNode()) { return Stream.of( new Reporting.Error( - "Expected a JSON-able value at " + path - + ", but got: " + node)); + "Expected a JSON-able value, but got: " + node)); } - if (node.isNull()) { + if (node.isNull() || node.isBoolean() || node.isTextual()) { return Stream.empty(); } if (node.isNumber()) { - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though a Jackson node holds either. - final double value = node.doubleValue(); - if (Double.isInfinite(value) || Double.isNaN(value)) { - return Stream.of( - new Reporting.Error( - "Expected a JSON-able value at " + path - + ", but got the number " + value - + ", which is neither finite nor representable in JSON")); - } - return Stream.empty(); - } - - if (node.isBoolean() || node.isTextual()) { - return Stream.empty(); + final String cause = checkJsonAbleNumber(node); + return (cause == null) + ? Stream.empty() + : Stream.of(new Reporting.Error(cause)); } if (node.isArray()) { return Verification.zip( IntStream.iterate(0, i -> i + 1).boxed(), StreamSupport.stream(node.spliterator(), false)) - .flatMap(elemTuple -> verifyJsonValueAt( - elemTuple.getSecond(), - path + "[" + elemTuple.getFirst() + "]")); + .flatMap(itemTuple -> verifyJsonValue(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); } if (node.isObject()) { return streamOfFieldNames(node) - .flatMap(name -> verifyJsonValueAt( - node.get(name), path + "." + name)); + .flatMap(name -> verifyJsonValue(node.get(name)) + .map(error -> { + error.prependSegment(new Reporting.KeySegment(name)); + return error; + })); } return Stream.of( new Reporting.Error( "Expected a JSON-able value (a null, a boolean, a number, a string, " - + "an array or an object) at " + path - + ", but got: " + node.getNodeType())); - } - - /** - * 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) { - return verifyJsonValueAt(node, "$"); + + "an array or an object), but got: " + node.getNodeType())); } /** diff --git a/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java b/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java index d4763b8c0..822fda558 100644 --- a/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java +++ b/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java @@ -10,6 +10,7 @@ import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; +import java.math.BigInteger; import java.util.Iterator; import java.util.regex.Pattern; import com.fasterxml.jackson.databind.JsonNode; @@ -17,6 +18,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import dummy.reporting.Reporting; +import dummy.verification.Verification; import dummy.xmlcommon.XmlCommon; /** @@ -214,17 +216,20 @@ private static Reporting.Result readXmlRpcDouble( // does a JSON document, so reading every double element as a double // node would silently turn the 1 of a document into a 1.0 on the way // through XML, and back again. + // + // Such an integer has to be a safe integer, exactly as in JSON. We read + // it as a big integer first, so that a numeral beyond a long is refused + // rather than truncated. if (text.indexOf('.') < 0 && text.indexOf('e') < 0 && text.indexOf('E') < 0) { - try { - return Reporting.Result.success( - JsonNodeFactory.instance.numberNode(Long.parseLong(text))); - } catch (NumberFormatException exception) { - // NOTE (mristin): - // The numeral does not fit a long, so it stays a double -- which - // is what a JSON parser would give for it as well. + final BigInteger integer = new BigInteger(text); + if (!Verification.isSafeInteger(integer)) { + return Reporting.Result.failure(new Reporting.Error( + Verification.jsonAbleIntegerOutsideSafeRangeMessage(integer))); } + return Reporting.Result.success( + JsonNodeFactory.instance.numberNode(integer.longValue())); } return Reporting.Result.success( @@ -481,15 +486,13 @@ private static void writeValueContent( } if (that.isNumber()) { - final double value = that.doubleValue(); - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, and readXmlRpcDouble refuses to read either back. - if (Double.isInfinite(value) || Double.isNaN(value)) { - throw new XmlCommon.SerializeFailure(new Reporting.Error( - "Expected a JSON-able value, but got the number " + value + - ", which is neither finite nor representable in JSON")); + // We check the number exactly as the verification and the JSON + // de/serialization do, and readXmlRpcDouble refuses to read back + // what we refuse here. + final String cause = Verification.checkJsonAbleNumber(that); + if (cause != null) { + throw new XmlCommon.SerializeFailure(new Reporting.Error(cause)); } XmlCommon.writeElementInNoNamespace( diff --git a/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..eb6a53c7b 100644 --- a/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/json_types_in_collections/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -177,6 +190,132 @@ public void testSomethingVerificationFail() throws IOException { } } } // public void testSomethingVerificationFail + + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + + @Test + public void testJsonValueOutsideTheSafeIntegerRangeFails() { + final JsonNode[] nodes = new JsonNode[] { + JsonNodeFactory.instance.numberNode(9007199254740992L), + JsonNodeFactory.instance.numberNode(-9007199254740992L), + JsonNodeFactory.instance.numberNode(BigInteger.ONE.shiftLeft(64)) + }; + + for (JsonNode node : nodes) { + final String expected = + "Expected a JSON-able value, but got the integer " + node + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List got = new ArrayList<>(); + Verification.verifyJsonValue(node).forEach( + error -> got.add(error.getCause())); + assertEquals(Arrays.asList(expected), got); + + try { + Jsonization.Deserialize.deserializeJsonValue(node); + fail("Expected the de-serialization of " + node + " to fail, but it succeeded"); + } catch (Jsonization.DeserializeException exception) { + assertEquals(expected, exception.getReason().orElse(null)); + } + + try { + Jsonization.Serialize.jsonValueToJsonNode(node); + fail("Expected the serialization of " + node + " to fail, but it succeeded"); + } catch (Jsonization.SerializeException exception) { + assertEquals(expected, exception.getReason().orElse(null)); + } + } + } // public void testJsonValueOutsideTheSafeIntegerRangeFails + + @Test + public void testJsonValueAtTheSafeIntegerBoundsOk() { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final JsonNode node = JsonNodeFactory.instance.numberNode(value); + + assertEquals(0L, Verification.verifyJsonValue(node).count()); + + assertEquals(node, Jsonization.Deserialize.deserializeJsonValue(node)); + + assertEquals(node, Jsonization.Serialize.jsonValueToJsonNode(node)); + } + } // public void testJsonValueAtTheSafeIntegerBoundsOk } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 3250bdb84..0d5c0ef52 100644 --- a/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -78,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ @@ -187,21 +176,21 @@ private static Reporting.Result missingRequiredProperty(String name) { *

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( - JsonNode node, String path) { + private static Reporting.Result tryJsonValueFrom(JsonNode node) { if (node == null || node.isMissingNode()) { return Reporting.Result.failure(new Reporting.Error( - "Expected a JSON-able value at " + path + ", but got: " + node)); + "Expected a JSON-able value, but got: " + node)); } if (node.isNull()) { @@ -209,15 +198,9 @@ private static Reporting.Result tryJsonValueFromAt( } if (node.isNumber()) { - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number. Jackson's own - // parser never gives us one, but {@code node} may well have been - // put together programmatically. - final double value = node.doubleValue(); - if (Double.isInfinite(value) || Double.isNaN(value)) { - return Reporting.Result.failure(new Reporting.Error( - "Expected a JSON-able value at " + path + ", but got the number " + - value + ", which is neither finite nor representable in JSON")); + final String cause = Verification.checkJsonAbleNumber(node); + if (cause != null) { + return Reporting.Result.failure(new Reporting.Error(cause)); } return Reporting.Result.success(node.deepCopy()); } @@ -230,9 +213,10 @@ private static Reporting.Result tryJsonValueFromAt( final ArrayNode result = JsonNodeFactory.instance.arrayNode(); int index = 0; for (JsonNode item : node) { - final Reporting.Result itemResult = tryJsonValueFromAt( - item, path + "[" + index + "]"); + final Reporting.Result itemResult = tryJsonValueFrom(item); if (itemResult.isError()) { + itemResult.getError().prependSegment( + new Reporting.IndexSegment(index)); return itemResult; } result.add(itemResult.getResult()); @@ -246,9 +230,11 @@ private static Reporting.Result tryJsonValueFromAt( final Iterator names = node.fieldNames(); while (names.hasNext()) { final String name = names.next(); - final Reporting.Result valueResult = tryJsonValueFromAt( - node.get(name), path + "." + name); + final Reporting.Result valueResult = tryJsonValueFrom( + node.get(name)); if (valueResult.isError()) { + valueResult.getError().prependSegment( + new Reporting.KeySegment(name)); return valueResult; } result.set(name, valueResult.getResult()); @@ -258,18 +244,7 @@ private static Reporting.Result tryJsonValueFromAt( return Reporting.Result.failure(new Reporting.Error( "Expected a JSON-able value (a null, a boolean, a number, a string, " + - "an array or an object) at " + path + ", but got: " + - node.getNodeType())); - } - - /** - * 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) { - return tryJsonValueFromAt(node, "$"); + "an array or an object), but got: " + node.getNodeType())); } /** @@ -466,6 +441,84 @@ Reporting.Error getError() { */ public static class Deserialize { + /** + * Deserialize a JSON-able value 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 value + */ + public static JsonNode deserializeJsonValue(JsonNode node) { + final Reporting.Result result = + _DeserializeImplementation.tryJsonValueFrom( + node); + + return result.onError(error -> { + throw new DeserializeException( + Reporting.generateJsonPath(error.getPathSegments()), + error.getCause()); + }); + } + + /** + * Deserialize a JSON-able array 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 array + */ + public static ArrayNode deserializeJsonArray(JsonNode node) { + final Reporting.Result result = + _DeserializeImplementation.tryJsonArrayFrom( + node); + + return result.onError(error -> { + throw new DeserializeException( + Reporting.generateJsonPath(error.getPathSegments()), + error.getCause()); + }); + } + + /** + * Deserialize a JSON-able object 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 object + */ + public static ObjectNode deserializeJsonObject(JsonNode node) { + final Reporting.Result result = + _DeserializeImplementation.tryJsonObjectFrom( + node); + + return result.onError(error -> { + throw new DeserializeException( + Reporting.generateJsonPath(error.getPathSegments()), + error.getCause()); + }); + } + /** * Deserialize an instance of Something from {@code node}. * @@ -567,9 +620,9 @@ private static void setOptionalProperty( * 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 @@ -590,12 +643,9 @@ private static JsonNode jsonValueToJsonNode(JsonNode that) { } if (that.isNumber()) { - final double value = that.doubleValue(); - if (Double.isInfinite(value) || Double.isNaN(value)) { - throw new _SerializeFailure( - new Reporting.Error( - "Expected a JSON-able value, but got the number " + value + - ", which is neither finite nor representable in JSON")); + final String cause = Verification.checkJsonAbleNumber(that); + if (cause != null) { + throw new _SerializeFailure(new Reporting.Error(cause)); } return that.deepCopy(); } @@ -698,6 +748,27 @@ public static JsonNode toJsonObject(IClass that) { error.getCause()); } } + + /** + * 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) { + try { + return _Transformer.jsonValueToJsonNode(that); + } catch (_SerializeFailure failure) { + final Reporting.Error error = failure.getError(); + throw new SerializeException( + Reporting.generateJavaPath(error.getPathSegments()), + error.getCause()); + } + } } } diff --git a/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/verification/Verification.java index 02bf4dfc8..be90a9601 100644 --- a/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/verification/Verification.java @@ -63,6 +63,50 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + /** * Stream the field names of {@code node}. * @@ -74,75 +118,121 @@ private static Stream streamOfFieldNames(JsonNode node) { return names.stream(); } + /** + * 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) { + return "Expected a JSON-able value, but got the integer " + value + + ", 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) { + switch (node.numberType()) { + case INT: + case LONG: + if (isSafeInteger(node.longValue())) { + return null; + } + break; + + case BIG_INTEGER: + // NOTE (mristin): + // Jackson gives a big integer for every integer if + // ``USE_BIG_INTEGER_FOR_INTS`` is enabled, so a small one can arrive + // as well. + if (isSafeInteger(node.bigIntegerValue())) { + return null; + } + break; + + default: { + // NOTE (mristin): + // JSON knows neither an infinity nor a not-a-number. Jackson's own + // parser never gives us one, but {@code node} may well have been + // put together programmatically. + final double value = node.doubleValue(); + if (Double.isInfinite(value) || Double.isNaN(value)) { + return "Expected a JSON-able value, but got the number " + value + + ", which is neither finite nor representable in JSON"; + } + return null; + } + } + + return jsonAbleIntegerOutsideSafeRangeMessage(node); + } + /** * 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( - JsonNode node, String path) { + public static Stream verifyJsonValue(JsonNode node) { if (node == null || node.isMissingNode()) { return Stream.of( new Reporting.Error( - "Expected a JSON-able value at " + path - + ", but got: " + node)); + "Expected a JSON-able value, but got: " + node)); } - if (node.isNull()) { + if (node.isNull() || node.isBoolean() || node.isTextual()) { return Stream.empty(); } if (node.isNumber()) { - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, even though a Jackson node holds either. - final double value = node.doubleValue(); - if (Double.isInfinite(value) || Double.isNaN(value)) { - return Stream.of( - new Reporting.Error( - "Expected a JSON-able value at " + path - + ", but got the number " + value - + ", which is neither finite nor representable in JSON")); - } - return Stream.empty(); - } - - if (node.isBoolean() || node.isTextual()) { - return Stream.empty(); + final String cause = checkJsonAbleNumber(node); + return (cause == null) + ? Stream.empty() + : Stream.of(new Reporting.Error(cause)); } if (node.isArray()) { return Verification.zip( IntStream.iterate(0, i -> i + 1).boxed(), StreamSupport.stream(node.spliterator(), false)) - .flatMap(elemTuple -> verifyJsonValueAt( - elemTuple.getSecond(), - path + "[" + elemTuple.getFirst() + "]")); + .flatMap(itemTuple -> verifyJsonValue(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); } if (node.isObject()) { return streamOfFieldNames(node) - .flatMap(name -> verifyJsonValueAt( - node.get(name), path + "." + name)); + .flatMap(name -> verifyJsonValue(node.get(name)) + .map(error -> { + error.prependSegment(new Reporting.KeySegment(name)); + return error; + })); } return Stream.of( new Reporting.Error( "Expected a JSON-able value (a null, a boolean, a number, a string, " - + "an array or an object) at " + path - + ", but got: " + node.getNodeType())); - } - - /** - * 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) { - return verifyJsonValueAt(node, "$"); + + "an array or an object), but got: " + node.getNodeType())); } /** diff --git a/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java b/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java index d4763b8c0..822fda558 100644 --- a/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java +++ b/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/main/java/dummy/xmlrpc/XmlRpc.java @@ -10,6 +10,7 @@ import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; +import java.math.BigInteger; import java.util.Iterator; import java.util.regex.Pattern; import com.fasterxml.jackson.databind.JsonNode; @@ -17,6 +18,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import dummy.reporting.Reporting; +import dummy.verification.Verification; import dummy.xmlcommon.XmlCommon; /** @@ -214,17 +216,20 @@ private static Reporting.Result readXmlRpcDouble( // does a JSON document, so reading every double element as a double // node would silently turn the 1 of a document into a 1.0 on the way // through XML, and back again. + // + // Such an integer has to be a safe integer, exactly as in JSON. We read + // it as a big integer first, so that a numeral beyond a long is refused + // rather than truncated. if (text.indexOf('.') < 0 && text.indexOf('e') < 0 && text.indexOf('E') < 0) { - try { - return Reporting.Result.success( - JsonNodeFactory.instance.numberNode(Long.parseLong(text))); - } catch (NumberFormatException exception) { - // NOTE (mristin): - // The numeral does not fit a long, so it stays a double -- which - // is what a JSON parser would give for it as well. + final BigInteger integer = new BigInteger(text); + if (!Verification.isSafeInteger(integer)) { + return Reporting.Result.failure(new Reporting.Error( + Verification.jsonAbleIntegerOutsideSafeRangeMessage(integer))); } + return Reporting.Result.success( + JsonNodeFactory.instance.numberNode(integer.longValue())); } return Reporting.Result.success( @@ -481,15 +486,13 @@ private static void writeValueContent( } if (that.isNumber()) { - final double value = that.doubleValue(); - // NOTE (mristin): - // JSON knows neither an infinity nor a not-a-number, so neither is - // a JSON-able value, and readXmlRpcDouble refuses to read either back. - if (Double.isInfinite(value) || Double.isNaN(value)) { - throw new XmlCommon.SerializeFailure(new Reporting.Error( - "Expected a JSON-able value, but got the number " + value + - ", which is neither finite nor representable in JSON")); + // We check the number exactly as the verification and the JSON + // de/serialization do, and readXmlRpcDouble refuses to read back + // what we refuse here. + final String cause = Verification.checkJsonAbleNumber(that); + if (cause != null) { + throw new XmlCommon.SerializeFailure(new Reporting.Error(cause)); } XmlCommon.writeElementInNoNamespace( diff --git a/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..f0d94a2fc 100644 --- a/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/json_types_in_invariants/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -177,6 +190,225 @@ public void testSomethingVerificationFail() throws IOException { } } } // public void testSomethingVerificationFail + + private static JsonNode loadTheFirstExpected(String modelType) throws IOException { + final List paths = + Common.findPaths( + Paths.get( + Common.TEST_DATA_DIR, + "Json", + "Expected", + modelType), + ".json"); + + if (paths.isEmpty()) { + fail("Expected at least one recorded example of " + modelType + ", but got none"); + } + + return CommonJson.readFromFile(paths.get(0)); + } + + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + + @Test + public void testSomethingMappingOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final ObjectNode node = JsonNodeFactory.instance.objectNode().put("key", value); + instance.setMapping(node); + + final String expected = + "Expected a JSON-able value, but got the integer " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("mapping[\"key\"]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingMappingOutsideTheSafeIntegerRange + + @Test + public void testSomethingMappingAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final ObjectNode node = JsonNodeFactory.instance.objectNode().put("key", value); + instance.setMapping(node); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingMappingAtTheSafeIntegerBounds + + @Test + public void testSomethingValuesOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final ArrayNode node = JsonNodeFactory.instance.arrayNode().add(value); + instance.setValues(node); + + final String expected = + "Expected a JSON-able value, but got the integer " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("values[0]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingValuesOutsideTheSafeIntegerRange + + @Test + public void testSomethingValuesAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + final ArrayNode node = JsonNodeFactory.instance.arrayNode().add(value); + instance.setValues(node); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingValuesAtTheSafeIntegerBounds + + @Test + public void testJsonValueOutsideTheSafeIntegerRangeFails() { + final JsonNode[] nodes = new JsonNode[] { + JsonNodeFactory.instance.numberNode(9007199254740992L), + JsonNodeFactory.instance.numberNode(-9007199254740992L), + JsonNodeFactory.instance.numberNode(BigInteger.ONE.shiftLeft(64)) + }; + + for (JsonNode node : nodes) { + final String expected = + "Expected a JSON-able value, but got the integer " + node + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List got = new ArrayList<>(); + Verification.verifyJsonValue(node).forEach( + error -> got.add(error.getCause())); + assertEquals(Arrays.asList(expected), got); + + try { + Jsonization.Deserialize.deserializeJsonValue(node); + fail("Expected the de-serialization of " + node + " to fail, but it succeeded"); + } catch (Jsonization.DeserializeException exception) { + assertEquals(expected, exception.getReason().orElse(null)); + } + + try { + Jsonization.Serialize.jsonValueToJsonNode(node); + fail("Expected the serialization of " + node + " to fail, but it succeeded"); + } catch (Jsonization.SerializeException exception) { + assertEquals(expected, exception.getReason().orElse(null)); + } + } + } // public void testJsonValueOutsideTheSafeIntegerRangeFails + + @Test + public void testJsonValueAtTheSafeIntegerBoundsOk() { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final JsonNode node = JsonNodeFactory.instance.numberNode(value); + + assertEquals(0L, Verification.verifyJsonValue(node).count()); + + assertEquals(node, Jsonization.Deserialize.deserializeJsonValue(node)); + + assertEquals(node, Jsonization.Serialize.jsonValueToJsonNode(node)); + } + } // public void testJsonValueAtTheSafeIntegerBoundsOk } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/jsonization/Jsonization.java index cf21b314c..94e3fc3ec 100644 --- a/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -819,18 +860,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/verification/Verification.java index a7197d9a1..973d72baa 100644 --- a/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/verification/Verification.java @@ -111,6 +111,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomeItem( @@ -127,7 +192,14 @@ public Stream transformAnotherItem( IAnotherItem that) { Stream errorStream = Stream.empty(); - // No verification has been defined for AnotherItem. + errorStream = Stream.concat(errorStream, + Stream.of(that.getSerialNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("serialNumber")); + return error; + })); return errorStream; } diff --git a/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 58b5e40c4..a94c05f12 100644 --- a/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/list_of_classes/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -222,11 +223,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result> readListOf_IAbstractItem( @@ -912,6 +920,26 @@ private static void writeClass( NESTED.visit(that, writer); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeListOf_IClass( List that, XMLStreamWriter writer) { @@ -959,7 +987,7 @@ private static void writeAnotherItemAsSequence( "getSerialNumber()", that.getSerialNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override diff --git a/dev/test_data/main/java/expected/list_of_classes/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/list_of_classes/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index fde77529b..6bf2e1929 100644 --- a/dev/test_data/main/java/expected/list_of_classes/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/list_of_classes/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -495,6 +508,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testAnotherItemSerialNumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -517,6 +609,42 @@ public void testAnotherItemSerialNumberSerializationOutofrange() throws IOExcept } } } // public void testAnotherItemSerialNumberSerializationOutofrange + + @Test + public void testAnotherItemSerialNumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final AnotherItem instance = + Jsonization.Deserialize.deserializeAnotherItem( + loadTheFirstExpected("AnotherItem")); + + instance.setSerialNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("serialNumber: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testAnotherItemSerialNumberOutsideTheSafeIntegerRange + + @Test + public void testAnotherItemSerialNumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final AnotherItem instance = + Jsonization.Deserialize.deserializeAnotherItem( + loadTheFirstExpected("AnotherItem")); + + instance.setSerialNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testAnotherItemSerialNumberAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/list_of_constrained_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/list_of_constrained_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 1b402a8ef..28957ff9b 100644 --- a/dev/test_data/main/java/expected/list_of_constrained_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/list_of_constrained_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/list_of_constrained_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/list_of_constrained_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..39ae68159 100644 --- a/dev/test_data/main/java/expected/list_of_constrained_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/list_of_constrained_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/list_of_enums/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/list_of_enums/expected_output/src/main/java/dummy/jsonization/Jsonization.java index f1cea35e4..97b4be756 100644 --- a/dev/test_data/main/java/expected/list_of_enums/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/list_of_enums/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/list_of_enums/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/list_of_enums/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..39ae68159 100644 --- a/dev/test_data/main/java/expected/list_of_enums/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/list_of_enums/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 85c72d76a..eb7d6fc13 100644 --- a/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -583,18 +624,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/verification/Verification.java index dea5008f6..c7c0f3f84 100644 --- a/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/verification/Verification.java @@ -170,6 +170,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomething( @@ -273,6 +338,15 @@ public Stream transformSomething( "Some floats must be unique."))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getSomeInts()) + .flatMap(Verification::verifyListOf_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("someInts")); + return error; + })); + return errorStream; } } @@ -323,6 +397,23 @@ public static Iterable verify(IClass that) { return new _ValidationErrorIterable(that); } + /** + * Verify the items of {@code that} recursively. + */ + private static Stream verifyListOf_long( + List that) { + return Verification.zip( + IntStream.iterate(0, i -> i + 1).boxed(), + that.stream()) + .flatMap(itemTuple -> + Verification.verify_long(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); + } + private static class _Pair { private final A first; private final B second; diff --git a/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/xmlization/Xmlization.java index b9575bc99..fd3b14791 100644 --- a/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -420,11 +421,18 @@ private static Reporting.Result> readListOf_bool( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result> readListOf_long( @@ -815,6 +823,26 @@ private static void writeByteArrayContent( Base64.getEncoder().encodeToString(that)); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeListOf_stringified( List that, XMLStreamWriter writer) { @@ -831,6 +859,22 @@ private static void writeListOf_stringified( } } + private static void writeListOf_long( + List that, + XMLStreamWriter writer) { + int index = 0; + try { + for (Long item : that) { + writeAtV_long(item, writer); + index++; + } + } catch (XmlCommon.SerializeFailure failure) { + failure.getError().prependSegment( + new Reporting.IndexSegment(index)); + throw failure; + } + } + private static void writeListOf_double( List that, XMLStreamWriter writer) { @@ -873,6 +917,16 @@ private static void writeAtV_stringified( XmlCommon::writeStringifiedContent); } + private static void writeAtV_long( + Long that, + XMLStreamWriter writer) { + XmlCommon.writeElement( + "v", + that, + writer, + _VisitorWithWriter::writeLongContent); + } + private static void writeAtV_double( Double that, XMLStreamWriter writer) { @@ -908,7 +962,7 @@ private static void writeSomethingAsSequence( "getSomeInts()", that.getSomeInts(), writer, - _VisitorWithWriter::writeListOf_stringified); + _VisitorWithWriter::writeListOf_long); writeProperty( "someFloats", diff --git a/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index f4b595508..5a942e27b 100644 --- a/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/list_of_primitives/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -198,6 +211,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSomethingSomeIntsSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -221,6 +313,42 @@ public void testSomethingSomeIntsSerializationOutofrange() throws IOException { } } // public void testSomethingSomeIntsSerializationOutofrange + @Test + public void testSomethingSomeIntsOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setSomeInts(Arrays.asList(0L, value)); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("someInts[1]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingSomeIntsOutsideTheSafeIntegerRange + + @Test + public void testSomethingSomeIntsAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setSomeInts(Arrays.asList(0L, value)); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingSomeIntsAtTheSafeIntegerBounds + @Test public void testSomethingSomeFloatsSerializationNonfinite() throws IOException { for (double value : new double[] {Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NaN}) { diff --git a/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/jsonization/Jsonization.java index ce618fb36..a3ebe1612 100644 --- a/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -15,6 +15,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -78,15 +80,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -548,18 +589,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } @Override diff --git a/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/verification/Verification.java index efddbd123..57e342261 100644 --- a/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/verification/Verification.java @@ -48,6 +48,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformBox( @@ -61,6 +126,15 @@ public Stream transformBox( "The size must be positive."))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getSize()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("size")); + return error; + })); + return errorStream; } } diff --git a/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/xmlization/Xmlization.java index a244f69a2..608fd7260 100644 --- a/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/method_references/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -183,11 +184,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } /** @@ -464,6 +472,26 @@ private static void writeProperty( } } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeBoxAsSequence( IBox that, XMLStreamWriter writer) { @@ -479,7 +507,7 @@ private static void writeBoxAsSequence( "getSize()", that.getSize(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override diff --git a/dev/test_data/main/java/expected/method_references/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/method_references/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 519a396ad..f4befde05 100644 --- a/dev/test_data/main/java/expected/method_references/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/method_references/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -198,6 +211,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testBoxSizeSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -220,6 +312,42 @@ public void testBoxSizeSerializationOutofrange() throws IOException { } } } // public void testBoxSizeSerializationOutofrange + + @Test + public void testBoxSizeOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Box instance = + Jsonization.Deserialize.deserializeBox( + loadTheFirstExpected("Box")); + + instance.setSize(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("size: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testBoxSizeOutsideTheSafeIntegerRange + + @Test + public void testBoxSizeAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Box instance = + Jsonization.Deserialize.deserializeBox( + loadTheFirstExpected("Box")); + + instance.setSize(value); + + assertNoSafeIntegerError(instance); + } + } // public void testBoxSizeAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/method_references/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java b/dev/test_data/main/java/expected/method_references/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java index fed617384..8568810d7 100644 --- a/dev/test_data/main/java/expected/method_references/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/method_references/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java @@ -358,6 +358,72 @@ public void testDuplicatePropertyFails() throws IOException, XMLStreamException + " is given twice, but got none"); } } // public void testDuplicatePropertyFails + + /** + * Read the first recorded example of Box with the content of + * the element {@code xmlName} replaced by {@code text}. + */ + private static Box readWith(String xmlName, String text) + throws IOException, XMLStreamException { + final Path searchPath = + Paths.get(Common.TEST_DATA_DIR, "Xml", "Expected", "box"); + final List paths = Common.findPaths(searchPath, ".xml"); + + if (paths.isEmpty()) { + fail( + "Expected at least one recorded example of box, but got none"); + } + + final String original = + new String(Files.readAllBytes(paths.get(0)), StandardCharsets.UTF_8); + + final int start = original.indexOf("<" + xmlName + ">") + xmlName.length() + 2; + final int end = original.indexOf(""); + + final String patched = + original.substring(0, start) + text + original.substring(end); + + final XMLInputFactory xmlInputFactory = XMLInputFactory.newInstance(); + final XMLEventReader xmlReader = + xmlInputFactory.createXMLEventReader(new StringReader(patched)); + + return Xmlization.Deserialize.deserializeBox(xmlReader); + } + + @Test + public void testSizeReadFrom9007199254740991() throws IOException, XMLStreamException { + final Box instance = + readWith("size", "9007199254740991"); + + assertEquals( + Long.valueOf(9007199254740991L), + instance.getSize()); + } // public void testSizeReadFrom9007199254740991 + + @Test + public void testSizeReadFromMinus9007199254740991() throws IOException, XMLStreamException { + final Box instance = + readWith("size", "-9007199254740991"); + + assertEquals( + Long.valueOf(-9007199254740991L), + instance.getSize()); + } // public void testSizeReadFromMinus9007199254740991 + + @Test + public void testSizeReadOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (String text : new String[] {"9007199254740992", "-9007199254740992"}) { + try { + readWith("size", text); + fail("Expected the de-serialization of " + text + " to fail, but it succeeded"); + } catch (XmlCommon.DeserializeException exception) { + assertEquals( + "Expected an integer, but got " + text + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception.getReason().orElse(null)); + } + } + } // public void testSizeReadOutsideTheSafeIntegerRange } // class TestXmlizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/nested_any_in_all/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/nested_any_in_all/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 3261c6249..5a7d54232 100644 --- a/dev/test_data/main/java/expected/nested_any_in_all/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/nested_any_in_all/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/nested_any_in_all/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/nested_any_in_all/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index d418d7673..099fd7d1c 100644 --- a/dev/test_data/main/java/expected/nested_any_in_all/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/nested_any_in_all/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 16752b36c..89c742916 100644 --- a/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -1192,18 +1233,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/verification/Verification.java index 00b29211c..780bb9546 100644 --- a/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/verification/Verification.java @@ -111,6 +111,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomeItem( @@ -132,7 +197,14 @@ public Stream transformAnotherItem( IAnotherItem that) { Stream errorStream = Stream.empty(); - // No verification has been defined for AnotherItem. + errorStream = Stream.concat(errorStream, + Stream.of(that.getSerialNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("serialNumber")); + return error; + })); return errorStream; } @@ -215,6 +287,15 @@ public Stream transformSomething( "Grid must be rectangular."))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getGrid()) + .flatMap(Verification::verifyListOf_ListOf_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("grid")); + return error; + })); + errorStream = Stream.concat(errorStream, Stream.of(that.getRowsOfPairs()) .flatMap(Verification::verifyListOf_ListOf_TupleOf2_PositiveInt_SetOf_string) @@ -330,7 +411,7 @@ public static Stream verifyColor( */ public static Stream verifyPositiveInt ( Long that) { - Stream errorStream = Stream.empty(); + Stream errorStream = verify_long(that); if (!(that > 0)) { errorStream = Stream.concat(errorStream, @@ -363,6 +444,40 @@ public static Stream verifyToErrorStream(IUnion that) { return verifyToErrorStream(that.getUnderlying()); } + /** + * Verify the items of {@code that} recursively. + */ + private static Stream verifyListOf_ListOf_long( + List> that) { + return Verification.zip( + IntStream.iterate(0, i -> i + 1).boxed(), + that.stream()) + .flatMap(itemTuple -> + Verification.verifyListOf_long(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); + } + + /** + * Verify the items of {@code that} recursively. + */ + private static Stream verifyListOf_long( + List that) { + return Verification.zip( + IntStream.iterate(0, i -> i + 1).boxed(), + that.stream()) + .flatMap(itemTuple -> + Verification.verify_long(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); + } + /** * Verify the items of {@code that} recursively. */ @@ -483,13 +598,21 @@ private static Stream verifySetOf_Color( */ private static Stream verifyTupleOf2_long_Code( Tuple2 that) { - return Stream.of(that.item2()) - .flatMap(Verification::verifyCode) - .map(error -> { - error.prependSegment( - new Reporting.IndexSegment(1)); - return error; - }); + return Stream.concat( + Stream.of(that.item1()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(0)); + return error; + }), + Stream.of(that.item2()) + .flatMap(Verification::verifyCode) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(1)); + return error; + })); } /** diff --git a/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 6f748aeb7..5b4e2d190 100644 --- a/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/nested_collections/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -26,6 +26,7 @@ import dummy.xmlcommon.XmlCommon; import java.util.HashSet; import java.util.Set; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -411,11 +412,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result> readListOf_long( @@ -1372,6 +1380,26 @@ private static void writeUnion( writeClass(that.getUnderlying(), writer); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + /** * Write the text of {@code that} as XML content. * @@ -1387,13 +1415,13 @@ private static void writeEnum( writer.writeCharacters(that.literalText()); } - private static void writeListOf_ListOf_stringified( - List> that, + private static void writeListOf_ListOf_long( + List> that, XMLStreamWriter writer) { int index = 0; try { - for (List item : that) { - writeAtV_ListOf_stringified(item, writer); + for (List item : that) { + writeAtV_ListOf_long(item, writer); index++; } } catch (XmlCommon.SerializeFailure failure) { @@ -1403,13 +1431,13 @@ private static void writeListOf_ListOf_stringified( } } - private static void writeListOf_stringified( - List that, + private static void writeListOf_long( + List that, XMLStreamWriter writer) { int index = 0; try { - for (Object item : that) { - writeAtV_stringified(item, writer); + for (Long item : that) { + writeAtV_long(item, writer); index++; } } catch (XmlCommon.SerializeFailure failure) { @@ -1419,13 +1447,13 @@ private static void writeListOf_stringified( } } - private static void writeListOf_ListOf_TupleOf2_stringified_SetOf_string( - List>>> that, + private static void writeListOf_ListOf_TupleOf2_long_SetOf_string( + List>>> that, XMLStreamWriter writer) { int index = 0; try { - for (List>> item : that) { - writeAtV_ListOf_TupleOf2_stringified_SetOf_string(item, writer); + for (List>> item : that) { + writeAtV_ListOf_TupleOf2_long_SetOf_string(item, writer); index++; } } catch (XmlCommon.SerializeFailure failure) { @@ -1435,13 +1463,13 @@ private static void writeListOf_ListOf_TupleOf2_stringified_SetOf_string( } } - private static void writeListOf_TupleOf2_stringified_SetOf_string( - List>> that, + private static void writeListOf_TupleOf2_long_SetOf_string( + List>> that, XMLStreamWriter writer) { int index = 0; try { - for (Tuple2> item : that) { - writeAtV_TupleOf2_stringified_SetOf_string(item, writer); + for (Tuple2> item : that) { + writeAtV_TupleOf2_long_SetOf_string(item, writer); index++; } } catch (XmlCommon.SerializeFailure failure) { @@ -1451,12 +1479,12 @@ private static void writeListOf_TupleOf2_stringified_SetOf_string( } } - private static void writeTupleOf2_stringified_SetOf_string( - Tuple2> that, + private static void writeTupleOf2_long_SetOf_string( + Tuple2> that, XMLStreamWriter writer) { int index = 0; try { - writeAtV1_stringified(that.item1(), writer); + writeAtV1_long(that.item1(), writer); index = 1; writeAtV2_SetOf_string(that.item2(), writer); } catch (XmlCommon.SerializeFailure failure) { @@ -1482,11 +1510,11 @@ private static void writeSetOf_string( } } - private static void writeTupleOf3_ListOf_IClass_SetOf_Color_TupleOf2_stringified_stringified( + private static void writeTupleOf3_ListOf_IClass_SetOf_Color_TupleOf2_long_stringified( Tuple3< ? extends List, ? extends Set, - ? extends Tuple2> that, + ? extends Tuple2> that, XMLStreamWriter writer) { int index = 0; try { @@ -1494,7 +1522,7 @@ private static void writeTupleOf3_ListOf_IClass_SetOf_Color_TupleOf2_stringified index = 1; writeAtV2_SetOf_Color(that.item2(), writer); index = 2; - writeAtV3_TupleOf2_stringified_stringified(that.item3(), writer); + writeAtV3_TupleOf2_long_stringified(that.item3(), writer); } catch (XmlCommon.SerializeFailure failure) { failure.getError().prependSegment( new Reporting.IndexSegment(index)); @@ -1534,12 +1562,12 @@ private static void writeSetOf_Color( } } - private static void writeTupleOf2_stringified_stringified( - Tuple2 that, + private static void writeTupleOf2_long_stringified( + Tuple2 that, XMLStreamWriter writer) { int index = 0; try { - writeAtV1_stringified(that.item1(), writer); + writeAtV1_long(that.item1(), writer); index = 1; writeAtV2_stringified(that.item2(), writer); } catch (XmlCommon.SerializeFailure failure) { @@ -1613,33 +1641,43 @@ private static void writeListOf_ListOf_IClass( } } - private static void writeAtV_stringified( - Object that, + private static void writeAtV_long( + Long that, XMLStreamWriter writer) { XmlCommon.writeElement( "v", that, writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } - private static void writeAtV_ListOf_stringified( - List that, + private static void writeAtV_ListOf_long( + List that, XMLStreamWriter writer) { XmlCommon.writeElement( "v", that, writer, - _VisitorWithWriter::writeListOf_stringified); + _VisitorWithWriter::writeListOf_long); } - private static void writeAtV1_stringified( - Object that, + private static void writeAtV1_long( + Long that, XMLStreamWriter writer) { XmlCommon.writeElement( "v1", that, writer, + _VisitorWithWriter::writeLongContent); + } + + private static void writeAtV_stringified( + Object that, + XMLStreamWriter writer) { + XmlCommon.writeElement( + "v", + that, + writer, XmlCommon::writeStringifiedContent); } @@ -1653,24 +1691,24 @@ private static void writeAtV2_SetOf_string( _VisitorWithWriter::writeSetOf_string); } - private static void writeAtV_TupleOf2_stringified_SetOf_string( - Tuple2> that, + private static void writeAtV_TupleOf2_long_SetOf_string( + Tuple2> that, XMLStreamWriter writer) { XmlCommon.writeElement( "v", that, writer, - _VisitorWithWriter::writeTupleOf2_stringified_SetOf_string); + _VisitorWithWriter::writeTupleOf2_long_SetOf_string); } - private static void writeAtV_ListOf_TupleOf2_stringified_SetOf_string( - List>> that, + private static void writeAtV_ListOf_TupleOf2_long_SetOf_string( + List>> that, XMLStreamWriter writer) { XmlCommon.writeElement( "v", that, writer, - _VisitorWithWriter::writeListOf_TupleOf2_stringified_SetOf_string); + _VisitorWithWriter::writeListOf_TupleOf2_long_SetOf_string); } private static void writeAtV1_ListOf_IClass( @@ -1713,14 +1751,14 @@ private static void writeAtV2_stringified( XmlCommon::writeStringifiedContent); } - private static void writeAtV3_TupleOf2_stringified_stringified( - Tuple2 that, + private static void writeAtV3_TupleOf2_long_stringified( + Tuple2 that, XMLStreamWriter writer) { XmlCommon.writeElement( "v3", that, writer, - _VisitorWithWriter::writeTupleOf2_stringified_stringified); + _VisitorWithWriter::writeTupleOf2_long_stringified); } private static void writeAtV_SetOf_Color( @@ -1784,7 +1822,7 @@ private static void writeAnotherItemAsSequence( "getSerialNumber()", that.getSerialNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override @@ -1807,21 +1845,21 @@ private static void writeSomethingAsSequence( "getGrid()", that.getGrid(), writer, - _VisitorWithWriter::writeListOf_ListOf_stringified); + _VisitorWithWriter::writeListOf_ListOf_long); writeProperty( "rowsOfPairs", "getRowsOfPairs()", that.getRowsOfPairs(), writer, - _VisitorWithWriter::writeListOf_ListOf_TupleOf2_stringified_SetOf_string); + _VisitorWithWriter::writeListOf_ListOf_TupleOf2_long_SetOf_string); writeProperty( "mixed", "getMixed()", that.getMixed(), writer, - _VisitorWithWriter::writeTupleOf3_ListOf_IClass_SetOf_Color_TupleOf2_stringified_stringified); + _VisitorWithWriter::writeTupleOf3_ListOf_IClass_SetOf_Color_TupleOf2_long_stringified); writeProperty( "setsOfColors", diff --git a/dev/test_data/main/java/expected/nested_collections/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/nested_collections/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index a77e79530..b0dc760e1 100644 --- a/dev/test_data/main/java/expected/nested_collections/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/nested_collections/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -396,6 +409,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testAnotherItemSerialNumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -418,6 +510,42 @@ public void testAnotherItemSerialNumberSerializationOutofrange() throws IOExcept } } } // public void testAnotherItemSerialNumberSerializationOutofrange + + @Test + public void testAnotherItemSerialNumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final AnotherItem instance = + Jsonization.Deserialize.deserializeAnotherItem( + loadTheFirstExpected("AnotherItem")); + + instance.setSerialNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("serialNumber: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testAnotherItemSerialNumberOutsideTheSafeIntegerRange + + @Test + public void testAnotherItemSerialNumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final AnotherItem instance = + Jsonization.Deserialize.deserializeAnotherItem( + loadTheFirstExpected("AnotherItem")); + + instance.setSerialNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testAnotherItemSerialNumberAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/jsonization/Jsonization.java index e35270872..6faf6c32a 100644 --- a/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -652,18 +693,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/verification/Verification.java index fa3f7aa24..150da2775 100644 --- a/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/verification/Verification.java @@ -133,6 +133,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformItem( @@ -207,6 +272,17 @@ public Stream transformSomething( return error; })); + if (that.getOptionalNumber().isPresent()) { + errorStream = Stream.concat(errorStream, + Stream.of(that.getOptionalNumber().get()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("optionalNumber")); + return error; + })); + } + if (that.getOptionalItem().isPresent()) { errorStream = Stream.concat(errorStream, Stream.of(that.getOptionalItem().get()) diff --git a/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 9023bf21b..cac567d92 100644 --- a/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/optional_arguments/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -247,11 +248,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result> readListOf_string( @@ -714,6 +722,26 @@ private static void writeOptionalProperty( } } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + /** * Write the text of {@code that} as XML content. * @@ -807,7 +835,7 @@ private static void writeSomethingAsSequence( "getOptionalNumber()", that.getOptionalNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeOptionalProperty( "optionalItem", diff --git a/dev/test_data/main/java/expected/optional_arguments/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/optional_arguments/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 829949422..5f70aa599 100644 --- a/dev/test_data/main/java/expected/optional_arguments/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/optional_arguments/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -276,6 +289,85 @@ public void testSomethingVerificationFail() throws IOException { } } } // public void testSomethingVerificationFail + + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/jsonization/Jsonization.java index d2960f952..815c37909 100644 --- a/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -15,6 +15,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -78,15 +80,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -487,18 +528,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/verification/Verification.java index f59c858fa..56becc3f0 100644 --- a/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/verification/Verification.java @@ -38,13 +38,85 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomething( ISomething that) { Stream errorStream = Stream.empty(); - // No verification has been defined for Something. + errorStream = Stream.concat(errorStream, + Stream.of(that.getSomeInt()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("someInt")); + return error; + })); return errorStream; } diff --git a/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 2c7ddc7a2..eb649c7e6 100644 --- a/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/primitive_types/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -375,11 +376,18 @@ private static Reporting.Result readTextAs_bool( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result readTextAs_double( @@ -706,6 +714,26 @@ private static void writeByteArrayContent( Base64.getEncoder().encodeToString(that)); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeSomethingAsSequence( ISomething that, XMLStreamWriter writer) { @@ -721,7 +749,7 @@ private static void writeSomethingAsSequence( "getSomeInt()", that.getSomeInt(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "someFloat", diff --git a/dev/test_data/main/java/expected/primitive_types/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/primitive_types/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index bf7404bbe..e88315950 100644 --- a/dev/test_data/main/java/expected/primitive_types/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/primitive_types/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -198,6 +211,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSomethingSomeIntSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -221,6 +313,42 @@ public void testSomethingSomeIntSerializationOutofrange() throws IOException { } } // public void testSomethingSomeIntSerializationOutofrange + @Test + public void testSomethingSomeIntOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setSomeInt(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("someInt: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingSomeIntOutsideTheSafeIntegerRange + + @Test + public void testSomethingSomeIntAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setSomeInt(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingSomeIntAtTheSafeIntegerBounds + @Test public void testSomethingSomeFloatSerializationNonfinite() throws IOException { for (double value : new double[] {Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NaN}) { diff --git a/dev/test_data/main/java/expected/primitive_types/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java b/dev/test_data/main/java/expected/primitive_types/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java index eab265fb0..7554bd23d 100644 --- a/dev/test_data/main/java/expected/primitive_types/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/primitive_types/expected_output/src/test/java/TestXmlizationOfConcreteClasses.java @@ -450,6 +450,41 @@ public void testSomeFloatReadFromMinusinf() throws IOException, XMLStreamExcepti instance.getSomeFloat()); } // public void testSomeFloatReadFromMinusinf + @Test + public void testSomeIntReadFrom9007199254740991() throws IOException, XMLStreamException { + final Something instance = + readWith("someInt", "9007199254740991"); + + assertEquals( + Long.valueOf(9007199254740991L), + instance.getSomeInt()); + } // public void testSomeIntReadFrom9007199254740991 + + @Test + public void testSomeIntReadFromMinus9007199254740991() throws IOException, XMLStreamException { + final Something instance = + readWith("someInt", "-9007199254740991"); + + assertEquals( + Long.valueOf(-9007199254740991L), + instance.getSomeInt()); + } // public void testSomeIntReadFromMinus9007199254740991 + + @Test + public void testSomeIntReadOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (String text : new String[] {"9007199254740992", "-9007199254740992"}) { + try { + readWith("someInt", text); + fail("Expected the de-serialization of " + text + " to fail, but it succeeded"); + } catch (XmlCommon.DeserializeException exception) { + assertEquals( + "Expected an integer, but got " + text + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + exception.getReason().orElse(null)); + } + } + } // public void testSomeIntReadOutsideTheSafeIntegerRange + @Test public void testSomeBoolReadFrom1() throws IOException, XMLStreamException { final Something instance = diff --git a/dev/test_data/main/java/expected/problematic_keywords/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/problematic_keywords/expected_output/src/main/java/dummy/jsonization/Jsonization.java index fe1404e12..fb2bb381e 100644 --- a/dev/test_data/main/java/expected/problematic_keywords/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/problematic_keywords/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -77,18 +77,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/problematic_keywords/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/problematic_keywords/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..39ae68159 100644 --- a/dev/test_data/main/java/expected/problematic_keywords/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/problematic_keywords/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 8abb49587..99d4d0bbf 100644 --- a/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -927,18 +968,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/verification/Verification.java index bade3cf1e..297e13952 100644 --- a/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/verification/Verification.java @@ -320,6 +320,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomething( @@ -452,6 +517,15 @@ public Stream transformSomething( "Texts must be unique."))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("number")); + return error; + })); + errorStream = Stream.concat(errorStream, Stream.of(that.getKind()) .flatMap(Verification::verifyKind) @@ -461,6 +535,15 @@ public Stream transformSomething( return error; })); + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumbers()) + .flatMap(Verification::verifyListOf_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("numbers")); + return error; + })); + errorStream = Stream.concat(errorStream, Stream.of(that.getKinds()) .flatMap(Verification::verifyListOf_Kind) @@ -546,6 +629,15 @@ public Stream transformCollection( "Texts must not contain the forbidden text."))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumbers()) + .flatMap(Verification::verifySetOf_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("numbers")); + return error; + })); + errorStream = Stream.concat(errorStream, Stream.of(that.getDirections()) .flatMap(Verification::verifySetOf_Direction) @@ -668,6 +760,23 @@ public static Stream verifyCode ( return errorStream; } + /** + * Verify the items of {@code that} recursively. + */ + private static Stream verifyListOf_long( + List that) { + return Verification.zip( + IntStream.iterate(0, i -> i + 1).boxed(), + that.stream()) + .flatMap(itemTuple -> + Verification.verify_long(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); + } + /** * Verify the items of {@code that} recursively. */ @@ -702,6 +811,23 @@ private static Stream verifyListOf_Code( })); } + /** + * Verify the items of {@code that} recursively. + */ + private static Stream verifySetOf_long( + Set that) { + return Verification.zip( + IntStream.iterate(0, i -> i + 1).boxed(), + SetHelpers.sorted(that).stream()) + .flatMap(itemTuple -> + Verification.verify_long(itemTuple.getSecond()) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(itemTuple.getFirst())); + return error; + })); + } + /** * Verify the items of {@code that} recursively. */ diff --git a/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/xmlization/Xmlization.java index ccff65f7d..28e258239 100644 --- a/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/sets/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -26,6 +26,7 @@ import dummy.xmlcommon.XmlCommon; import java.util.HashSet; import java.util.Set; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -327,11 +328,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result readTextAs_Kind( @@ -1083,6 +1091,26 @@ private static void writeOptionalProperty( } } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + /** * Write the text of {@code that} as XML content. * @@ -1114,6 +1142,22 @@ private static void writeListOf_stringified( } } + private static void writeListOf_long( + List that, + XMLStreamWriter writer) { + int index = 0; + try { + for (Long item : that) { + writeAtV_long(item, writer); + index++; + } + } catch (XmlCommon.SerializeFailure failure) { + failure.getError().prependSegment( + new Reporting.IndexSegment(index)); + throw failure; + } + } + private static void writeListOf_IEnum( List that, XMLStreamWriter writer) { @@ -1151,8 +1195,8 @@ private static void writeSetOf_long( XMLStreamWriter writer) { int index = 0; try { - for (Object item : SetHelpers.sorted(that)) { - writeAtV_stringified(item, writer); + for (Long item : SetHelpers.sorted(that)) { + writeAtV_long(item, writer); index++; } } catch (XmlCommon.SerializeFailure failure) { @@ -1204,6 +1248,16 @@ private static void writeAtV_stringified( XmlCommon::writeStringifiedContent); } + private static void writeAtV_long( + Long that, + XMLStreamWriter writer) { + XmlCommon.writeElement( + "v", + that, + writer, + _VisitorWithWriter::writeLongContent); + } + private static void writeAtV_IEnum( IEnum that, XMLStreamWriter writer) { @@ -1229,7 +1283,7 @@ private static void writeSomethingAsSequence( "getNumber()", that.getNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeProperty( "kind", @@ -1250,7 +1304,7 @@ private static void writeSomethingAsSequence( "getNumbers()", that.getNumbers(), writer, - _VisitorWithWriter::writeListOf_stringified); + _VisitorWithWriter::writeListOf_long); writeProperty( "kinds", diff --git a/dev/test_data/main/java/expected/sets/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/sets/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 74eff31d3..b8659591b 100644 --- a/dev/test_data/main/java/expected/sets/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/sets/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -297,6 +310,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSomethingNumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -320,6 +412,42 @@ public void testSomethingNumberSerializationOutofrange() throws IOException { } } // public void testSomethingNumberSerializationOutofrange + @Test + public void testSomethingNumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("number: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingNumberOutsideTheSafeIntegerRange + + @Test + public void testSomethingNumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingNumberAtTheSafeIntegerBounds + @Test public void testSomethingNumbersSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -342,6 +470,42 @@ public void testSomethingNumbersSerializationOutofrange() throws IOException { } } } // public void testSomethingNumbersSerializationOutofrange + + @Test + public void testSomethingNumbersOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumbers(Arrays.asList(0L, value)); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("numbers[1]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingNumbersOutsideTheSafeIntegerRange + + @Test + public void testSomethingNumbersAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumbers(Arrays.asList(0L, value)); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingNumbersAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/string_operations/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/string_operations/expected_output/src/main/java/dummy/jsonization/Jsonization.java index c2ff6f966..083e4a2a9 100644 --- a/dev/test_data/main/java/expected/string_operations/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/string_operations/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -77,18 +77,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/string_operations/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/string_operations/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index ba2a3ba27..39ae68159 100644 --- a/dev/test_data/main/java/expected/string_operations/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/string_operations/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java index e4d59f18c..cf191792c 100644 --- a/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -15,6 +15,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -79,15 +81,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -519,18 +560,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } @Override diff --git a/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/verification/Verification.java index 441c417bd..460d3ceb7 100644 --- a/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/verification/Verification.java @@ -259,6 +259,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomething( @@ -362,6 +427,15 @@ public Stream transformSomething( return error; })); + errorStream = Stream.concat(errorStream, + Stream.of(that.getNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("number")); + return error; + })); + return errorStream; } } diff --git a/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 9f0283f84..0219306a2 100644 --- a/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/switch_statements/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -217,11 +218,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } /** @@ -485,6 +493,26 @@ private static void writeProperty( } } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + /** * Write the text of {@code that} as XML content. * @@ -529,7 +557,7 @@ private static void writeSomethingAsSequence( "getNumber()", that.getNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override diff --git a/dev/test_data/main/java/expected/switch_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/switch_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index c2db4e1ee..4e00a8473 100644 --- a/dev/test_data/main/java/expected/switch_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/switch_statements/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -198,6 +211,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSomethingNumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -220,6 +312,42 @@ public void testSomethingNumberSerializationOutofrange() throws IOException { } } } // public void testSomethingNumberSerializationOutofrange + + @Test + public void testSomethingNumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("number: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingNumberOutsideTheSafeIntegerRange + + @Test + public void testSomethingNumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingNumberAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 140df4cb2..ae0cc0021 100644 --- a/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -790,18 +831,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/verification/Verification.java index a048f635d..7da25f4c2 100644 --- a/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/verification/Verification.java @@ -65,6 +65,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformFirst( @@ -99,6 +164,15 @@ public Stream transformFirst( "Parity is either zero or one."))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getCount()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("count")); + return error; + })); + if (that.getKind().isPresent()) { errorStream = Stream.concat(errorStream, Stream.of(that.getKind().get()) @@ -153,6 +227,15 @@ public Stream transformSecond( "Prefix is at most three long."))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getCount()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("count")); + return error; + })); + if (that.getKind().isPresent()) { errorStream = Stream.concat(errorStream, Stream.of(that.getKind().get()) diff --git a/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/xmlization/Xmlization.java index 2c9cb725a..7779e96d1 100644 --- a/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -253,11 +254,18 @@ private static Reporting.Result> readListOf_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result readTextAs_Kind( @@ -766,6 +774,26 @@ private static void writeOptionalProperty( } } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + /** * Write the text of {@code that} as XML content. * @@ -822,7 +850,7 @@ private static void writeFirstAsSequence( "getCount()", that.getCount(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeOptionalProperty( "kind", @@ -859,7 +887,7 @@ private static void writeSecondAsSequence( "getCount()", that.getCount(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); writeOptionalProperty( "kind", diff --git a/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index c4631d0f2..862e05144 100644 --- a/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/transpiled_methods/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -297,6 +310,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testFirstCountSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -320,6 +412,42 @@ public void testFirstCountSerializationOutofrange() throws IOException { } } // public void testFirstCountSerializationOutofrange + @Test + public void testFirstCountOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final First instance = + Jsonization.Deserialize.deserializeFirst( + loadTheFirstExpected("First")); + + instance.setCount(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("count: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testFirstCountOutsideTheSafeIntegerRange + + @Test + public void testFirstCountAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final First instance = + Jsonization.Deserialize.deserializeFirst( + loadTheFirstExpected("First")); + + instance.setCount(value); + + assertNoSafeIntegerError(instance); + } + } // public void testFirstCountAtTheSafeIntegerBounds + @Test public void testSecondCountSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -342,6 +470,42 @@ public void testSecondCountSerializationOutofrange() throws IOException { } } } // public void testSecondCountSerializationOutofrange + + @Test + public void testSecondCountOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Second instance = + Jsonization.Deserialize.deserializeSecond( + loadTheFirstExpected("Second")); + + instance.setCount(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("count: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSecondCountOutsideTheSafeIntegerRange + + @Test + public void testSecondCountAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Second instance = + Jsonization.Deserialize.deserializeSecond( + loadTheFirstExpected("Second")); + + instance.setCount(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSecondCountAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 505a50372..e0f4bf807 100644 --- a/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -965,18 +1006,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/verification/Verification.java index 70f052ad0..ddf6c32b5 100644 --- a/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/verification/Verification.java @@ -77,6 +77,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomeItem( @@ -93,7 +158,14 @@ public Stream transformAnotherItem( IAnotherItem that) { Stream errorStream = Stream.empty(); - // No verification has been defined for AnotherItem. + errorStream = Stream.concat(errorStream, + Stream.of(that.getSerialNumber()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("serialNumber")); + return error; + })); return errorStream; } @@ -126,6 +198,15 @@ public Stream transformSomething( "The optional pair must have two items"))); } + errorStream = Stream.concat(errorStream, + Stream.of(that.getPair()) + .flatMap(Verification::verifyTupleOf2_string_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("pair")); + return error; + })); + errorStream = Stream.concat(errorStream, Stream.of(that.getItems()) .flatMap(Verification::verifyTupleOf2_IAbstractItem_IAbstractItem) @@ -210,7 +291,7 @@ public static Iterable verify(IClass that) { */ public static Stream verifyPositiveInt ( Long that) { - Stream errorStream = Stream.empty(); + Stream errorStream = verify_long(that); if (!(that > 0)) { errorStream = Stream.concat(errorStream, @@ -235,6 +316,20 @@ public static Stream verifyResult( } } + /** + * Verify the items of {@code that} recursively. + */ + private static Stream verifyTupleOf2_string_long( + Tuple2 that) { + return Stream.of(that.item2()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(1)); + return error; + }); + } + /** * Verify the items of {@code that} recursively. */ @@ -272,13 +367,21 @@ private static Stream verifyTupleOf6_long_ISomeItem_IAbstractIt Stream.concat( Stream.concat( Stream.concat( - Stream.of(that.item2()) - .flatMap(Verification::verifyToErrorStream) - .map(error -> { - error.prependSegment( - new Reporting.IndexSegment(1)); - return error; - }), + Stream.concat( + Stream.of(that.item1()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(0)); + return error; + }), + Stream.of(that.item2()) + .flatMap(Verification::verifyToErrorStream) + .map(error -> { + error.prependSegment( + new Reporting.IndexSegment(1)); + return error; + })), Stream.of(that.item3()) .flatMap(Verification::verifyToErrorStream) .map(error -> { diff --git a/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/xmlization/Xmlization.java index a8bf33b68..fcca50b3b 100644 --- a/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/tuples/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -370,11 +371,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result> readTupleOf2_string_long( @@ -1077,6 +1085,26 @@ private static void writeClass( NESTED.visit(that, writer); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + /** * Write the text of {@code that} as XML content. * @@ -1092,14 +1120,14 @@ private static void writeEnum( writer.writeCharacters(that.literalText()); } - private static void writeTupleOf2_stringified_stringified( - Tuple2 that, + private static void writeTupleOf2_stringified_long( + Tuple2 that, XMLStreamWriter writer) { int index = 0; try { writeAtV1_stringified(that.item1(), writer); index = 1; - writeAtV2_stringified(that.item2(), writer); + writeAtV2_long(that.item2(), writer); } catch (XmlCommon.SerializeFailure failure) { failure.getError().prependSegment( new Reporting.IndexSegment(index)); @@ -1122,18 +1150,18 @@ private static void writeTupleOf2_IClass_IClass( } } - private static void writeTupleOf6_stringified_IClass_IClass_IClass_stringified_IEnum( + private static void writeTupleOf6_long_IClass_IClass_IClass_long_IEnum( Tuple6< - ?, + ? extends Long, ? extends IClass, ? extends IClass, ? extends IClass, - ?, + ? extends Long, ? extends IEnum> that, XMLStreamWriter writer) { int index = 0; try { - writeAtV1_stringified(that.item1(), writer); + writeAtV1_long(that.item1(), writer); index = 1; writeClass(that.item2(), writer); index = 2; @@ -1141,7 +1169,7 @@ private static void writeTupleOf6_stringified_IClass_IClass_IClass_stringified_I index = 3; writeClass(that.item4(), writer); index = 4; - writeAtV5_stringified(that.item5(), writer); + writeAtV5_long(that.item5(), writer); index = 5; writeAtV6_IEnum(that.item6(), writer); } catch (XmlCommon.SerializeFailure failure) { @@ -1176,24 +1204,34 @@ private static void writeAtV1_stringified( XmlCommon::writeStringifiedContent); } - private static void writeAtV2_stringified( - Object that, + private static void writeAtV2_long( + Long that, XMLStreamWriter writer) { XmlCommon.writeElement( "v2", that, writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } - private static void writeAtV5_stringified( - Object that, + private static void writeAtV1_long( + Long that, + XMLStreamWriter writer) { + XmlCommon.writeElement( + "v1", + that, + writer, + _VisitorWithWriter::writeLongContent); + } + + private static void writeAtV5_long( + Long that, XMLStreamWriter writer) { XmlCommon.writeElement( "v5", that, writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } private static void writeAtV6_IEnum( @@ -1237,7 +1275,7 @@ private static void writeAnotherItemAsSequence( "getSerialNumber()", that.getSerialNumber(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override @@ -1260,7 +1298,7 @@ private static void writeSomethingAsSequence( "getPair()", that.getPair(), writer, - _VisitorWithWriter::writeTupleOf2_stringified_stringified); + _VisitorWithWriter::writeTupleOf2_stringified_long); writeProperty( "items", @@ -1274,7 +1312,7 @@ private static void writeSomethingAsSequence( "getTricky()", that.getTricky(), writer, - _VisitorWithWriter::writeTupleOf6_stringified_IClass_IClass_IClass_stringified_IEnum); + _VisitorWithWriter::writeTupleOf6_long_IClass_IClass_IClass_long_IEnum); writeOptionalProperty( "optionalPair", diff --git a/dev/test_data/main/java/expected/tuples/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/tuples/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index d98f3d8a1..5aef840dc 100644 --- a/dev/test_data/main/java/expected/tuples/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/tuples/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -396,6 +409,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testAnotherItemSerialNumberSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -419,6 +511,42 @@ public void testAnotherItemSerialNumberSerializationOutofrange() throws IOExcept } } // public void testAnotherItemSerialNumberSerializationOutofrange + @Test + public void testAnotherItemSerialNumberOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final AnotherItem instance = + Jsonization.Deserialize.deserializeAnotherItem( + loadTheFirstExpected("AnotherItem")); + + instance.setSerialNumber(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("serialNumber: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testAnotherItemSerialNumberOutsideTheSafeIntegerRange + + @Test + public void testAnotherItemSerialNumberAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final AnotherItem instance = + Jsonization.Deserialize.deserializeAnotherItem( + loadTheFirstExpected("AnotherItem")); + + instance.setSerialNumber(value); + + assertNoSafeIntegerError(instance); + } + } // public void testAnotherItemSerialNumberAtTheSafeIntegerBounds + @Test public void testSomethingPairSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -445,6 +573,48 @@ public void testSomethingPairSerializationOutofrange() throws IOException { } } // public void testSomethingPairSerializationOutofrange + @Test + public void testSomethingPairOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setPair( + new Tuple2( + instance.getPair().item1(), + value)); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("pair[1]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingPairOutsideTheSafeIntegerRange + + @Test + public void testSomethingPairAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setPair( + new Tuple2( + instance.getPair().item1(), + value)); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingPairAtTheSafeIntegerBounds + @Test public void testSomethingTrickySerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -480,6 +650,68 @@ public void testSomethingTrickySerializationOutofrange() throws IOException { } } } // public void testSomethingTrickySerializationOutofrange + + @Test + public void testSomethingTrickyOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setTricky( + new Tuple6< + Long, + ISomeItem, + IAbstractItem, + ISomeItem, + Long, + Result>( + value, + instance.getTricky().item2(), + instance.getTricky().item3(), + instance.getTricky().item4(), + instance.getTricky().item5(), + instance.getTricky().item6())); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("tricky[0]: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSomethingTrickyOutsideTheSafeIntegerRange + + @Test + public void testSomethingTrickyAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final Something instance = + Jsonization.Deserialize.deserializeSomething( + loadTheFirstExpected("Something")); + + instance.setTricky( + new Tuple6< + Long, + ISomeItem, + IAbstractItem, + ISomeItem, + Long, + Result>( + value, + instance.getTricky().item2(), + instance.getTricky().item3(), + instance.getTricky().item4(), + instance.getTricky().item5(), + instance.getTricky().item6())); + + assertNoSafeIntegerError(instance); + } + } // public void testSomethingTrickyAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/unions/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/unions/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 2d1bd2198..348bb9127 100644 --- a/dev/test_data/main/java/expected/unions/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/unions/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -79,18 +79,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/unions/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/unions/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index 61f47f98e..72b1d1d8d 100644 --- a/dev/test_data/main/java/expected/unions/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/unions/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 3607b9e9f..e5a583dd5 100644 --- a/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import dummy.verification.Verification; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; @@ -80,15 +82,54 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { } /** 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) { - if (!value.isIntegralNumber()) { + if (!value.isNumber()) { final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); + "Expected an integer, but got: " + value.getNodeType()); return Reporting.Result.failure(error); } - return Reporting.Result.success(value.asLong()); + + switch (value.numberType()) { + case INT: + case LONG: { + final long integer = value.longValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer); + } + break; + } + + case BIG_INTEGER: { + final BigInteger integer = value.bigIntegerValue(); + if (Verification.isSafeInteger(integer)) { + return Reporting.Result.success(integer.longValue()); + } + break; + } + + default: { + final double number = value.doubleValue(); + if (!Double.isFinite(number) || Math.rint(number) != number) { + return Reporting.Result.failure(new Reporting.Error( + "Expected an integer, but got " + number + + ", which is not a whole number")); + } + if (Verification.isSafeInteger(number)) { + return Reporting.Result.success((long) number); + } + break; + } + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(value))); } /** Convert {@code value} to a double-precision 64-bit float. @@ -904,18 +945,13 @@ private static JsonNode stringToJsonNode(String that) { * @param that value to be converted */ private static JsonNode longToJsonNode(Long that) { - // NOTE (mristin): - // Outside this range 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. final long primitiveThat = that.longValue(); - if (primitiveThat < -9007199254740991L || primitiveThat > 9007199254740991L) { + if (!Verification.isSafeInteger(primitiveThat)) { throw new _SerializeFailure( new Reporting.Error( - "The integer can not be serialized to JSON as it is outside " - + "the range [-2^53 + 1, 2^53 - 1]: " + that)); + Verification.integerOutsideSafeRangeMessage(primitiveThat))); } - return JsonNodeFactory.instance.numberNode(that); + return JsonNodeFactory.instance.numberNode(primitiveThat); } /** diff --git a/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/verification/Verification.java b/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/verification/Verification.java index d4797ea50..d475124a9 100644 --- a/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/verification/Verification.java +++ b/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/verification/Verification.java @@ -334,6 +334,71 @@ private static class _EnumValueSet { private static final _Transformer transformer = new _Transformer(); + /** + * 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) { + return -9007199254740991L <= value && value <= 9007199254740991L; + } + + /** + * 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) { + return -9007199254740991.0 <= value && value <= 9007199254740991.0; + } + + /** + * 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) { + // NOTE (mristin): + // A bit length below 64 means that the value fits a long, so we can + // narrow it without truncating it silently. + return value.bitLength() < 64 && isSafeInteger(value.longValue()); + } + + /** + * 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) { + return "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + } + + /** + * Verify that the integer {@code that} lies in the safe integer range. + */ + private static Stream verify_long(long that) { + return isSafeInteger(that) + ? Stream.empty() + : Stream.of(new Reporting.Error(integerOutsideSafeRangeMessage(that))); + } + private static class _Transformer extends AbstractTransformer> { @Override public Stream transformSomeItem( @@ -350,7 +415,14 @@ public Stream transformSpecialItem( ISpecialItem that) { Stream errorStream = Stream.empty(); - // No verification has been defined for SpecialItem. + errorStream = Stream.concat(errorStream, + Stream.of(that.getLevel()) + .flatMap(Verification::verify_long) + .map(error -> { + error.prependSegment( + new Reporting.NameSegment("level")); + return error; + })); return errorStream; } diff --git a/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/xmlization/Xmlization.java b/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/xmlization/Xmlization.java index cbbf6c23e..82eaf953b 100644 --- a/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/xmlization/Xmlization.java +++ b/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/main/java/dummy/xmlization/Xmlization.java @@ -24,6 +24,7 @@ import dummy.types.model.*; import dummy.visitation.*; import dummy.xmlcommon.XmlCommon; +import dummy.verification.Verification; /** * Provide de/serialization of meta-model classes to/from XML. @@ -222,11 +223,18 @@ private static Reporting.Result readTextAs_string( private static Reporting.Result readTextAs_long( XMLEventReader reader, boolean isEmpty) { - return readText( + final Reporting.Result result = readText( reader, isEmpty, _DeserializeImplementation::readContentAsLong, "Long"); + + if (result.isError() || Verification.isSafeInteger(result.getResult())) { + return result; + } + + return Reporting.Result.failure(new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(result.getResult()))); } private static Reporting.Result> readListOf_IItem( @@ -1047,6 +1055,26 @@ private static void writeClass( NESTED.visit(that, writer); } + /** + * 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( + Long that, + XMLStreamWriter writer) throws XMLStreamException { + final long primitiveThat = that.longValue(); + if (!Verification.isSafeInteger(primitiveThat)) { + throw new XmlCommon.SerializeFailure( + new Reporting.Error( + Verification.integerOutsideSafeRangeMessage(primitiveThat))); + } + writer.writeCharacters(Long.toString(primitiveThat)); + } + private static void writeListOf_IClass( List that, XMLStreamWriter writer) { @@ -1115,7 +1143,7 @@ private static void writeSpecialItemAsSequence( "getLevel()", that.getLevel(), writer, - XmlCommon::writeStringifiedContent); + _VisitorWithWriter::writeLongContent); } @Override diff --git a/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index e4cdae03e..b860720a6 100644 --- a/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/variance_of_containers/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -16,19 +19,29 @@ import dummy.types.enums.*; import dummy.types.model.*; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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 { @@ -495,6 +508,85 @@ private static JsonNode loadTheFirstExpected(String modelType) throws IOExceptio return CommonJson.readFromFile(paths.get(0)); } + /** + * Render the verification errors of {@code instance} as + * {@code "path: cause"}. + */ + private static List verificationErrors(IClass instance) { + final List result = new ArrayList<>(); + for (Reporting.Error error : Verification.verify(instance)) { + result.add( + Reporting.generateJsonPath(error.getPathSegments()) + + ": " + error.getCause()); + } + return result; + } + + /** + * Serialize {@code instance} to JSON, and give the reason why it failed, + * or null if it did not. + */ + private static String jsonSerializationFailure(IClass instance) { + try { + Jsonization.Serialize.toJsonObject(instance); + return null; + } catch (Jsonization.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * Serialize {@code instance} to XML, and give the reason why it failed, + * or null if it did not. + */ + private static String xmlSerializationFailure(IClass instance) + throws XMLStreamException { + final XMLStreamWriter writer = + XMLOutputFactory.newFactory().createXMLStreamWriter(new StringWriter()); + try { + Xmlization.Serialize.to(instance, writer); + return null; + } catch (XmlCommon.SerializeException exception) { + return exception.getReason().orElse(null); + } + } + + /** + * 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) + throws XMLStreamException { + for (String error : verificationErrors(instance)) { + if (error.contains("outside the safe integer range")) { + fail("Expected no safe integer error, but got: " + error); + } + } + + assertNull(jsonSerializationFailure(instance)); + assertNull(xmlSerializationFailure(instance)); + } + + @Test + public void testIsSafeInteger() { + assertTrue(Verification.isSafeInteger(9007199254740991L)); + assertTrue(Verification.isSafeInteger(-9007199254740991L)); + assertFalse(Verification.isSafeInteger(9007199254740992L)); + assertFalse(Verification.isSafeInteger(-9007199254740992L)); + + assertTrue(Verification.isSafeInteger(9007199254740991.0)); + assertTrue(Verification.isSafeInteger(-9007199254740991.0)); + assertFalse(Verification.isSafeInteger(9007199254740992.0)); + assertFalse(Verification.isSafeInteger(-9007199254740992.0)); + + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(9007199254740991L))); + assertTrue(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740991L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.valueOf(-9007199254740992L))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64))); + assertFalse(Verification.isSafeInteger(BigInteger.ONE.shiftLeft(64).negate())); + } // public void testIsSafeInteger + @Test public void testSpecialItemLevelSerializationOutofrange() throws IOException { for (long value : new long[] {9007199254740992L, -9007199254740992L}) { @@ -517,6 +609,42 @@ public void testSpecialItemLevelSerializationOutofrange() throws IOException { } } } // public void testSpecialItemLevelSerializationOutofrange + + @Test + public void testSpecialItemLevelOutsideTheSafeIntegerRange() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740992L, -9007199254740992L}) { + final SpecialItem instance = + Jsonization.Deserialize.deserializeSpecialItem( + loadTheFirstExpected("SpecialItem")); + + instance.setLevel(value); + + final String expected = + "Expected an integer, but got " + value + + ", which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + final List errors = verificationErrors(instance); + if (!errors.contains("level: " + expected)) { + fail("Expected the verification error " + expected + ", but got: " + errors); + } + + assertEquals(expected, jsonSerializationFailure(instance)); + assertEquals(expected, xmlSerializationFailure(instance)); + } + } // public void testSpecialItemLevelOutsideTheSafeIntegerRange + + @Test + public void testSpecialItemLevelAtTheSafeIntegerBounds() throws IOException, XMLStreamException { + for (long value : new long[] {9007199254740991L, -9007199254740991L}) { + final SpecialItem instance = + Jsonization.Deserialize.deserializeSpecialItem( + loadTheFirstExpected("SpecialItem")); + + instance.setLevel(value); + + assertNoSafeIntegerError(instance); + } + } // public void testSpecialItemLevelAtTheSafeIntegerBounds } // class TestJsonizationOfConcreteClasses // package dummy.tests diff --git a/dev/test_data/main/java/expected/visibility/expected_output/src/main/java/dummy/jsonization/Jsonization.java b/dev/test_data/main/java/expected/visibility/expected_output/src/main/java/dummy/jsonization/Jsonization.java index 10d9dc511..01a9bc746 100644 --- a/dev/test_data/main/java/expected/visibility/expected_output/src/main/java/dummy/jsonization/Jsonization.java +++ b/dev/test_data/main/java/expected/visibility/expected_output/src/main/java/dummy/jsonization/Jsonization.java @@ -77,18 +77,6 @@ private static Reporting.Result tryBooleanFrom(JsonNode value) { return Reporting.Result.success(value.asBoolean()); } - /** Convert {@code value} to a long 64-bit integer. - * @param node JSON node to be parsed - */ - private static Reporting.Result tryLongFrom(JsonNode value) { - if (!value.isIntegralNumber()) { - final Reporting.Error error = new Reporting.Error( - "Expected a JsonValue of Long, but got " + value.getNodeType()); - return Reporting.Result.failure(error); - } - return Reporting.Result.success(value.asLong()); - } - /** Convert {@code value} to a double-precision 64-bit float. * @param node JSON node to be parsed */ diff --git a/dev/test_data/main/java/expected/visibility/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java b/dev/test_data/main/java/expected/visibility/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java index d8d9242d6..1e23fdd6f 100644 --- a/dev/test_data/main/java/expected/visibility/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java +++ b/dev/test_data/main/java/expected/visibility/expected_output/src/test/java/TestJsonizationOfConcreteClasses.java @@ -6,6 +6,9 @@ package dummy.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 dummy.jsonization.Jsonization; @@ -13,19 +16,29 @@ import dummy.types.impl.*; import dummy.types.model.IClass; import dummy.verification.Verification; +import dummy.xmlcommon.XmlCommon; +import dummy.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/dev/test_data/main/python/expected/annotated_assignments/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 93a99bbbd..ccfc93744 100644 --- a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -134,59 +134,134 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_child_a_a_only_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.child_a_from_jsonable( - _load_the_first_expected('ChildA') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_child_a_a_only(self) -> None: + jsonable = _load_the_first_expected('ChildA') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['aOnly'] = 5.0 + instance = our_jsonization.child_a_from_jsonable(jsonable) + self.assertIs(int, type(instance.a_only)) + self.assertEqual(5, instance.a_only) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.a_only = value + ]: + jsonable['aOnly'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.child_a_from_jsonable(jsonable) self.assertEqual( - '.a_only', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_child_b_b_only_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.child_b_from_jsonable( - _load_the_first_expected('ChildB') - ) + def test_child_b_b_only(self) -> None: + jsonable = _load_the_first_expected('ChildB') - instance.b_only = value + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['bOnly'] = 5.0 + instance = our_jsonization.child_b_from_jsonable(jsonable) + self.assertIs(int, type(instance.b_only)) + self.assertEqual(5, instance.b_only) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['bOnly'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.child_b_from_jsonable(jsonable) self.assertEqual( - '.b_only', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_something_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) + def test_something_number(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['number'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.number)) + self.assertEqual(5, instance.number) - instance.number = value + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['number'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.number', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..44c09425e --- /dev/null +++ b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,254 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_child_a_a_only_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.child_a_from_jsonable( + _load_the_first_expected('ChildA') + ) + + instance.a_only = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.a_only', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.a_only', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.a_only', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_child_a_a_only_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.child_a_from_jsonable( + _load_the_first_expected('ChildA') + ) + + instance.a_only = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_child_b_b_only_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.child_b_from_jsonable( + _load_the_first_expected('ChildB') + ) + + instance.b_only = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.b_only', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.b_only', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.b_only', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_child_b_b_only_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.child_b_from_jsonable( + _load_the_first_expected('ChildB') + ) + + instance.b_only = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/common.py b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/jsonization.py index cb5bd5a26..16d779a5e 100644 --- a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/jsonization.py @@ -350,6 +350,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -357,11 +364,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -991,20 +1018,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/verification.py index b31c78b39..34531b1d4 100644 --- a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/verification.py @@ -48,6 +48,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -420,24 +421,28 @@ def transform_child_a( self, that: our_types.ChildA ) -> Iterator[Error]: - # No verification has been defined for ChildA. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.a_only): + error.path._prepend( + PropertySegment( + that, + 'a_only' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_child_b( self, that: our_types.ChildB ) -> Iterator[Error]: - # No verification has been defined for ChildB. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.b_only): + error.path._prepend( + PropertySegment( + that, + 'b_only' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_something( @@ -580,6 +585,15 @@ def transform_something( 'The first parent as Child_a must have a small a_only' ) + for error in _verify_int(that.number): + error.path._prepend( + PropertySegment( + that, + 'number' + ) + ) + yield error + for error in verify_code(that.code): error.path._prepend( PropertySegment( @@ -652,6 +666,14 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + def _verify_list_of__class( that: Sequence[our_types.Class] ) -> Iterator[Error]: diff --git a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/xmlization.py index cd4e11199..e07b917ca 100644 --- a/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/annotated_assignments/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -1707,7 +1708,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1758,6 +1760,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -2638,6 +2649,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/arithmetic/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/arithmetic/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 885e3512a..39a7af263 100644 --- a/dev/test_data/main/python/expected/arithmetic/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/arithmetic/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -64,13 +64,13 @@ def _load_the_first_expected(model_type: str) -> Any: class TestSerializationFailures(unittest.TestCase): - def test_something_even_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: + def test_something_ratio_non_finite(self) -> None: + for value in [math.inf, -math.inf, math.nan]: instance = our_jsonization.something_from_jsonable( _load_the_first_expected('Something') ) - instance.even = value + instance.ratio = value with self.assertRaises( our_jsonization.SerializationException @@ -78,134 +78,311 @@ def test_something_even_out_of_range(self) -> None: our_jsonization.to_jsonable(instance) self.assertEqual( - '.even', + '.ratio', context_manager.exception.path ) - def test_something_offset_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) - instance.offset = value +class TestDeserializationOfIntegers(unittest.TestCase): + def test_something_even(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['even'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.even)) + self.assertEqual(5, instance.even) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['even'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.offset', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_something_small_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) + def test_something_offset(self) -> None: + jsonable = _load_the_first_expected('Something') - instance.small = value + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['offset'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.offset)) + self.assertEqual(5, instance.offset) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['offset'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.small', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_something_by_negative_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) + def test_something_small(self) -> None: + jsonable = _load_the_first_expected('Something') - instance.by_negative = value + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['small'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.small)) + self.assertEqual(5, instance.small) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['small'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.by_negative', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_something_negative_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) + def test_something_by_negative(self) -> None: + jsonable = _load_the_first_expected('Something') - instance.negative = value + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['byNegative'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.by_negative)) + self.assertEqual(5, instance.by_negative) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['byNegative'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.negative', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_something_deviation_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) + def test_something_negative(self) -> None: + jsonable = _load_the_first_expected('Something') - instance.deviation = value + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['negative'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.negative)) + self.assertEqual(5, instance.negative) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['negative'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.deviation', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_something_ratio_non_finite(self) -> None: - for value in [math.inf, -math.inf, math.nan]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) + def test_something_deviation(self) -> None: + jsonable = _load_the_first_expected('Something') - instance.ratio = value + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['deviation'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.deviation)) + self.assertEqual(5, instance.deviation) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['deviation'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.ratio', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_something_alignment_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) + def test_something_alignment(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['alignment'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.alignment)) + self.assertEqual(5, instance.alignment) - instance.alignment = value + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['alignment'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.alignment', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/arithmetic/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/arithmetic/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..4ecd38cde --- /dev/null +++ b/dev/test_data/main/python/expected/arithmetic/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,498 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_something_even_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.even = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.even', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.even', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.even', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_even_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.even = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_offset_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.offset = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.offset', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.offset', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.offset', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_offset_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.offset = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_small_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.small = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.small', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.small', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.small', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_small_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.small = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_by_negative_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.by_negative = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.by_negative', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.by_negative', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.by_negative', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_by_negative_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.by_negative = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_negative_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.negative = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.negative', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.negative', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.negative', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_negative_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.negative = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_deviation_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.deviation = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.deviation', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.deviation', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.deviation', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_deviation_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.deviation = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_alignment_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.alignment = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.alignment', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.alignment', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.alignment', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_alignment_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.alignment = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/common.py b/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/jsonization.py index a67a25d0c..d17f55928 100644 --- a/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/jsonization.py @@ -291,6 +291,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -298,11 +305,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _float_from_jsonable( @@ -552,20 +579,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/verification.py index 3f0bea55d..d472fdae5 100644 --- a/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -224,6 +225,70 @@ def transform_something( ) yield error + for error in _verify_int(that.offset): + error.path._prepend( + PropertySegment( + that, + 'offset' + ) + ) + yield error + + for error in _verify_int(that.small): + error.path._prepend( + PropertySegment( + that, + 'small' + ) + ) + yield error + + for error in _verify_int(that.by_negative): + error.path._prepend( + PropertySegment( + that, + 'by_negative' + ) + ) + yield error + + for error in _verify_int(that.negative): + error.path._prepend( + PropertySegment( + that, + 'negative' + ) + ) + yield error + + for error in _verify_int(that.deviation): + error.path._prepend( + PropertySegment( + that, + 'deviation' + ) + ) + yield error + + for error in _verify_int(that.alignment): + error.path._prepend( + PropertySegment( + that, + 'alignment' + ) + ) + yield error + + if that.optional_number is not None: + for error in _verify_int(that.optional_number): + error.path._prepend( + PropertySegment( + that, + 'optional_number' + ) + ) + yield error + _TRANSFORMER = _Transformer() @@ -240,10 +305,20 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + def verify_even_int( that: int ) -> Iterator[Error]: """Verify the constraints of :paramref:`that`.""" + yield from _verify_int(that) + if not ((that % 2) == (0)): yield Error( 'Even' diff --git a/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/xmlization.py index 86d01e5ab..4ad653b36 100644 --- a/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/arithmetic/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -753,7 +754,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -804,6 +806,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1173,6 +1184,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index c7eb107c1..76362631c 100644 --- a/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -110,77 +110,177 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_child_a_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.child_a_from_jsonable( - _load_the_first_expected('ChildA') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_child_a_number(self) -> None: + jsonable = _load_the_first_expected('ChildA') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['number'] = 5.0 + instance = our_jsonization.child_a_from_jsonable(jsonable) + self.assertIs(int, type(instance.number)) + self.assertEqual(5, instance.number) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.number = value + ]: + jsonable['number'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.child_a_from_jsonable(jsonable) self.assertEqual( - '.number', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_child_a_a_only_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.child_a_from_jsonable( - _load_the_first_expected('ChildA') - ) + def test_child_a_a_only(self) -> None: + jsonable = _load_the_first_expected('ChildA') - instance.a_only = value + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['aOnly'] = 5.0 + instance = our_jsonization.child_a_from_jsonable(jsonable) + self.assertIs(int, type(instance.a_only)) + self.assertEqual(5, instance.a_only) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['aOnly'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.child_a_from_jsonable(jsonable) self.assertEqual( - '.a_only', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_child_b_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.child_b_from_jsonable( - _load_the_first_expected('ChildB') - ) + def test_child_b_number(self) -> None: + jsonable = _load_the_first_expected('ChildB') - instance.number = value + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['number'] = 5.0 + instance = our_jsonization.child_b_from_jsonable(jsonable) + self.assertIs(int, type(instance.number)) + self.assertEqual(5, instance.number) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['number'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.child_b_from_jsonable(jsonable) self.assertEqual( - '.number', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_something_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) + def test_something_number(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['number'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.number)) + self.assertEqual(5, instance.number) - instance.number = value + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['number'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.number', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..d8e7a9296 --- /dev/null +++ b/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,315 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_child_a_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.child_a_from_jsonable( + _load_the_first_expected('ChildA') + ) + + instance.number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_child_a_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.child_a_from_jsonable( + _load_the_first_expected('ChildA') + ) + + instance.number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_child_a_a_only_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.child_a_from_jsonable( + _load_the_first_expected('ChildA') + ) + + instance.a_only = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.a_only', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.a_only', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.a_only', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_child_a_a_only_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.child_a_from_jsonable( + _load_the_first_expected('ChildA') + ) + + instance.a_only = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_child_b_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.child_b_from_jsonable( + _load_the_first_expected('ChildB') + ) + + instance.number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_child_b_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.child_b_from_jsonable( + _load_the_first_expected('ChildB') + ) + + instance.number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_xmlization_of_concrete_classes.py b/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_xmlization_of_concrete_classes.py index ee6d5d122..20c93194e 100644 --- a/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_xmlization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/assert_statements/expected_output/dev/tests/test_xmlization_of_concrete_classes.py @@ -238,6 +238,24 @@ def test_number_read_from_many_digits_fails(self) -> None: with self.assertRaises(our_xmlization.DeserializationException): self._read_with('number', "1" + "0" * 5000) + def test_number_read_at_the_safe_bounds(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = self._read_with('number', str(value)) + self.assertEqual(value, instance.number) + + def test_number_read_outside_the_safe_range_fails(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + with self.assertRaises( + our_xmlization.DeserializationException + ) as context_manager: + self._read_with('number', str(value)) + + self.assertEqual( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + context_manager.exception.cause + ) + class TestDuplicateProperty(unittest.TestCase): """Test that a property given more than once is refused.""" diff --git a/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/common.py b/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/jsonization.py index e2619fcd5..eaea36409 100644 --- a/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/jsonization.py @@ -332,6 +332,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -339,11 +346,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -721,20 +748,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/verification.py index dc945d69a..b0aafbd8b 100644 --- a/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -139,24 +140,37 @@ def transform_child_a( self, that: our_types.ChildA ) -> Iterator[Error]: - # No verification has been defined for ChildA. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.number): + error.path._prepend( + PropertySegment( + that, + 'number' + ) + ) + yield error + + for error in _verify_int(that.a_only): + error.path._prepend( + PropertySegment( + that, + 'a_only' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_child_b( self, that: our_types.ChildB ) -> Iterator[Error]: - # No verification has been defined for ChildB. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.number): + error.path._prepend( + PropertySegment( + that, + 'number' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_something( @@ -205,6 +219,15 @@ def transform_something( 'Number must be between minus one thousand and one thousand' ) + for error in _verify_int(that.number): + error.path._prepend( + PropertySegment( + that, + 'number' + ) + ) + yield error + for error in verify(that.parent): error.path._prepend( PropertySegment( @@ -230,5 +253,13 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/xmlization.py index f56fe18a0..08b27210e 100644 --- a/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/assert_statements/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -1410,7 +1411,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1461,6 +1463,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1958,6 +1969,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 5047cdfc3..8e469b71a 100644 --- a/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -64,13 +64,13 @@ def _load_the_first_expected(model_type: str) -> Any: class TestSerializationFailures(unittest.TestCase): - def test_something_count_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: + def test_something_ratio_non_finite(self) -> None: + for value in [math.inf, -math.inf, math.nan]: instance = our_jsonization.something_from_jsonable( _load_the_first_expected('Something') ) - instance.count = value + instance.ratio = value with self.assertRaises( our_jsonization.SerializationException @@ -78,26 +78,53 @@ def test_something_count_out_of_range(self) -> None: our_jsonization.to_jsonable(instance) self.assertEqual( - '.count', + '.ratio', context_manager.exception.path ) - def test_something_ratio_non_finite(self) -> None: - for value in [math.inf, -math.inf, math.nan]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) - instance.ratio = value +class TestDeserializationOfIntegers(unittest.TestCase): + def test_something_count(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['count'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.count)) + self.assertEqual(5, instance.count) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['count'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.ratio', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..c645fa017 --- /dev/null +++ b/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_something_count_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.count = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.count', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.count', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.count', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_count_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.count = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_xmlization_of_concrete_classes.py b/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_xmlization_of_concrete_classes.py index 4db1f4da9..586039cfb 100644 --- a/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_xmlization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/comparisons/expected_output/dev/tests/test_xmlization_of_concrete_classes.py @@ -159,6 +159,24 @@ def test_count_read_from_many_digits_fails(self) -> None: with self.assertRaises(our_xmlization.DeserializationException): self._read_with('count', "1" + "0" * 5000) + def test_count_read_at_the_safe_bounds(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = self._read_with('count', str(value)) + self.assertEqual(value, instance.count) + + def test_count_read_outside_the_safe_range_fails(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + with self.assertRaises( + our_xmlization.DeserializationException + ) as context_manager: + self._read_with('count', str(value)) + + self.assertEqual( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + context_manager.exception.cause + ) + class TestDuplicateProperty(unittest.TestCase): """Test that a property given more than once is refused.""" diff --git a/dev/test_data/main/python/expected/comparisons/expected_output/dummy/common.py b/dev/test_data/main/python/expected/comparisons/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/comparisons/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/comparisons/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/comparisons/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/comparisons/expected_output/dummy/jsonization.py index 1d6d248a8..5336b959e 100644 --- a/dev/test_data/main/python/expected/comparisons/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/comparisons/expected_output/dummy/jsonization.py @@ -308,6 +308,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -315,11 +322,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" + ) + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) ) - return jsonable + + return value def _float_from_jsonable( @@ -525,20 +552,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/comparisons/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/comparisons/expected_output/dummy/verification.py index 154d6afbf..8f6bc6fed 100644 --- a/dev/test_data/main/python/expected/comparisons/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/comparisons/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -164,6 +165,15 @@ def transform_something( 'Flag implies the other flag' ) + for error in _verify_int(that.count): + error.path._prepend( + PropertySegment( + that, + 'count' + ) + ) + yield error + _TRANSFORMER = _Transformer() @@ -180,5 +190,13 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/comparisons/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/comparisons/expected_output/dummy/xmlization.py index c49da8584..85a3ee900 100644 --- a/dev/test_data/main/python/expected/comparisons/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/comparisons/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -793,7 +794,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -844,6 +846,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1201,6 +1212,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index ac25bb3d4..689af4693 100644 --- a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -64,13 +64,13 @@ def _load_the_first_expected(model_type: str) -> Any: class TestSerializationFailures(unittest.TestCase): - def test_something_some_int_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: + def test_something_some_float_non_finite(self) -> None: + for value in [math.inf, -math.inf, math.nan]: instance = our_jsonization.something_from_jsonable( _load_the_first_expected('Something') ) - instance.some_int = value + instance.some_float = value with self.assertRaises( our_jsonization.SerializationException @@ -78,26 +78,53 @@ def test_something_some_int_out_of_range(self) -> None: our_jsonization.to_jsonable(instance) self.assertEqual( - '.some_int', + '.some_float', context_manager.exception.path ) - def test_something_some_float_non_finite(self) -> None: - for value in [math.inf, -math.inf, math.nan]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) - instance.some_float = value +class TestDeserializationOfIntegers(unittest.TestCase): + def test_something_some_int(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['someInt'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.some_int)) + self.assertEqual(5, instance.some_int) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['someInt'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.some_float', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..98b5cc96d --- /dev/null +++ b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_something_some_int_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.some_int = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.some_int', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.some_int', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.some_int', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_some_int_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.some_int = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/common.py b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/jsonization.py index 1f3d3ec0e..421a8e26f 100644 --- a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/jsonization.py @@ -309,6 +309,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -316,11 +323,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" + ) + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) ) - return jsonable + + return value def _float_from_jsonable( @@ -546,20 +573,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/verification.py index 5edfb0ac3..d7a8858bb 100644 --- a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -133,6 +134,14 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + def verify_constrainedbool( that: bool ) -> Iterator[Error]: @@ -147,6 +156,8 @@ def verify_positiveint( that: int ) -> Iterator[Error]: """Verify the constraints of :paramref:`that`.""" + yield from _verify_int(that) + if not (that > 0): yield Error( 'Larger than zero' diff --git a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/xmlization.py index 235d4012d..4172e00b6 100644 --- a/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/constrained_primitives/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -871,7 +872,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -922,6 +924,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1336,6 +1347,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index d23780324..d8e4ac549 100644 --- a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -182,41 +182,91 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_leaf_value_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.leaf_from_jsonable( - _load_the_first_expected('Leaf') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_leaf_value(self) -> None: + jsonable = _load_the_first_expected('Leaf') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['value'] = 5.0 + instance = our_jsonization.leaf_from_jsonable(jsonable) + self.assertIs(int, type(instance.value)) + self.assertEqual(5, instance.value) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.value = value + ]: + jsonable['value'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.leaf_from_jsonable(jsonable) self.assertEqual( - '.value', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_blossom_value_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.blossom_from_jsonable( - _load_the_first_expected('Blossom') - ) + def test_blossom_value(self) -> None: + jsonable = _load_the_first_expected('Blossom') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['value'] = 5.0 + instance = our_jsonization.blossom_from_jsonable(jsonable) + self.assertIs(int, type(instance.value)) + self.assertEqual(5, instance.value) - instance.value = value + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['value'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.blossom_from_jsonable(jsonable) self.assertEqual( - '.value', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..25ac8e587 --- /dev/null +++ b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,193 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_leaf_value_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.leaf_from_jsonable( + _load_the_first_expected('Leaf') + ) + + instance.value = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.value', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.value', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.value', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_leaf_value_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.leaf_from_jsonable( + _load_the_first_expected('Leaf') + ) + + instance.value = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_blossom_value_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.blossom_from_jsonable( + _load_the_first_expected('Blossom') + ) + + instance.value = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.value', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.value', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.value', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_blossom_value_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.blossom_from_jsonable( + _load_the_first_expected('Blossom') + ) + + instance.value = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/common.py b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/jsonization.py index f1de19f72..4de64c502 100644 --- a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/jsonization.py @@ -332,6 +332,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -339,11 +346,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -936,20 +963,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/verification.py index 7ad5dc204..37a4548bf 100644 --- a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -84,24 +85,28 @@ def transform_leaf( self, that: our_types.Leaf ) -> Iterator[Error]: - # No verification has been defined for Leaf. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.value): + error.path._prepend( + PropertySegment( + that, + 'value' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_blossom( self, that: our_types.Blossom ) -> Iterator[Error]: - # No verification has been defined for Blossom. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.value): + error.path._prepend( + PropertySegment( + that, + 'value' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_plain_marker( @@ -177,5 +182,13 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/xmlization.py index 42d60ba7f..88765d805 100644 --- a/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/deep_class_hierarchy/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -2060,7 +2061,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -2111,6 +2113,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -2897,6 +2908,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/for_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/for_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 283ca3687..db9697b65 100644 --- a/dev/test_data/main/python/expected/for_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/for_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -6,7 +6,6 @@ import json -from typing import Any import unittest @@ -67,45 +66,6 @@ def test_something(self) -> None: self.assertListEqual([], list(map(str, mismatches))) -def _load_the_first_expected(model_type: str) -> Any: - """Load the first recorded example of the ``model_type``.""" - paths = sorted( - ( - tests.common.TEST_DATA_DIR - / "Json" - / "Expected" - / model_type - ).glob("**/*.json") - ) - - assert len(paths) > 0, ( - f"Expected at least one recorded example of {model_type}, but got none" - ) - - with paths[0].open("rt") as fid: - return json.load(fid) - - -class TestSerializationFailures(unittest.TestCase): - def test_something_numbers_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) - - instance.numbers = [0, value] - - with self.assertRaises( - our_jsonization.SerializationException - ) as context_manager: - our_jsonization.to_jsonable(instance) - - self.assertEqual( - '.numbers[1]', - context_manager.exception.path - ) - - if __name__ == "__main__": unittest.main() diff --git a/dev/test_data/main/python/expected/for_statements/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/for_statements/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..00c2ac2f0 --- /dev/null +++ b/dev/test_data/main/python/expected/for_statements/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_something_numbers_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.numbers = [0, value] + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.numbers[1]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.numbers[1]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.numbers[1]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_numbers_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.numbers = [0, value] + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/for_statements/expected_output/dummy/common.py b/dev/test_data/main/python/expected/for_statements/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/for_statements/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/for_statements/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/for_statements/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/for_statements/expected_output/dummy/jsonization.py index c4e8fcba7..50285eb07 100644 --- a/dev/test_data/main/python/expected/for_statements/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/for_statements/expected_output/dummy/jsonization.py @@ -290,6 +290,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -297,11 +304,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -610,20 +637,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/for_statements/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/for_statements/expected_output/dummy/verification.py index f2a4383ea..a1b119b96 100644 --- a/dev/test_data/main/python/expected/for_statements/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/for_statements/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -341,6 +342,15 @@ def transform_something( 'The first text must not be empty' ) + for error in _verify_list_of__int(that.numbers): + error.path._prepend( + PropertySegment( + that, + 'numbers' + ) + ) + yield error + for error in _verify_list_of__class(that.items): error.path._prepend( PropertySegment( @@ -366,6 +376,29 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + +def _verify_list_of__int( + that: Sequence[int] +) -> Iterator[Error]: + """Verify the items of :paramref:`that` recursively.""" + for i, item in enumerate(that): + for error in _verify_int(item): + error.path._prepend( + IndexSegment( + that, + i + ) + ) + yield error + + def _verify_list_of__class( that: Sequence[our_types.Class] ) -> Iterator[Error]: diff --git a/dev/test_data/main/python/expected/for_statements/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/for_statements/expected_output/dummy/xmlization.py index ed8af90f6..0954b3978 100644 --- a/dev/test_data/main/python/expected/for_statements/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/for_statements/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -998,7 +999,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1049,6 +1051,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1512,6 +1523,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/if_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/if_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 20e3a4375..61705317e 100644 --- a/dev/test_data/main/python/expected/if_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/if_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -158,59 +158,134 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_child_a_a_only_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.child_a_from_jsonable( - _load_the_first_expected('ChildA') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_child_a_a_only(self) -> None: + jsonable = _load_the_first_expected('ChildA') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['aOnly'] = 5.0 + instance = our_jsonization.child_a_from_jsonable(jsonable) + self.assertIs(int, type(instance.a_only)) + self.assertEqual(5, instance.a_only) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.a_only = value + ]: + jsonable['aOnly'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.child_a_from_jsonable(jsonable) self.assertEqual( - '.a_only', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_child_b_b_only_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.child_b_from_jsonable( - _load_the_first_expected('ChildB') - ) + def test_child_b_b_only(self) -> None: + jsonable = _load_the_first_expected('ChildB') - instance.b_only = value + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['bOnly'] = 5.0 + instance = our_jsonization.child_b_from_jsonable(jsonable) + self.assertIs(int, type(instance.b_only)) + self.assertEqual(5, instance.b_only) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['bOnly'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.child_b_from_jsonable(jsonable) self.assertEqual( - '.b_only', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_something_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) + def test_something_number(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['number'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.number)) + self.assertEqual(5, instance.number) - instance.number = value + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['number'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.number', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/if_statements/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/if_statements/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..44c09425e --- /dev/null +++ b/dev/test_data/main/python/expected/if_statements/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,254 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_child_a_a_only_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.child_a_from_jsonable( + _load_the_first_expected('ChildA') + ) + + instance.a_only = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.a_only', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.a_only', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.a_only', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_child_a_a_only_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.child_a_from_jsonable( + _load_the_first_expected('ChildA') + ) + + instance.a_only = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_child_b_b_only_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.child_b_from_jsonable( + _load_the_first_expected('ChildB') + ) + + instance.b_only = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.b_only', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.b_only', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.b_only', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_child_b_b_only_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.child_b_from_jsonable( + _load_the_first_expected('ChildB') + ) + + instance.b_only = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/if_statements/expected_output/dummy/common.py b/dev/test_data/main/python/expected/if_statements/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/if_statements/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/if_statements/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/if_statements/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/if_statements/expected_output/dummy/jsonization.py index c105a4491..d4a88b27e 100644 --- a/dev/test_data/main/python/expected/if_statements/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/if_statements/expected_output/dummy/jsonization.py @@ -349,6 +349,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -356,11 +363,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -891,20 +918,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/if_statements/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/if_statements/expected_output/dummy/verification.py index 90eefda15..ddae65128 100644 --- a/dev/test_data/main/python/expected/if_statements/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/if_statements/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -384,24 +385,28 @@ def transform_child_a( self, that: our_types.ChildA ) -> Iterator[Error]: - # No verification has been defined for ChildA. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.a_only): + error.path._prepend( + PropertySegment( + that, + 'a_only' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_child_b( self, that: our_types.ChildB ) -> Iterator[Error]: - # No verification has been defined for ChildB. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.b_only): + error.path._prepend( + PropertySegment( + that, + 'b_only' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_container( @@ -538,6 +543,15 @@ def transform_something( 'Optional parent as Child_a must have a small a_only' ) + for error in _verify_int(that.number): + error.path._prepend( + PropertySegment( + that, + 'number' + ) + ) + yield error + for error in verify(that.item): error.path._prepend( PropertySegment( @@ -583,6 +597,14 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + def _verify_list_of__class( that: Sequence[our_types.Class] ) -> Iterator[Error]: diff --git a/dev/test_data/main/python/expected/if_statements/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/if_statements/expected_output/dummy/xmlization.py index 7241464db..1c3fa7b9a 100644 --- a/dev/test_data/main/python/expected/if_statements/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/if_statements/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -1808,7 +1809,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1859,6 +1861,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -2572,6 +2583,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 8aff9cbe5..ea48873fa 100644 --- a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -62,41 +62,48 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_something_numbers_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) - - instance.numbers = [0, value] - - with self.assertRaises( - our_jsonization.SerializationException - ) as context_manager: - our_jsonization.to_jsonable(instance) - - self.assertEqual( - '.numbers[1]', - context_manager.exception.path - ) - - def test_something_count_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_something_count(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['count'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.count)) + self.assertEqual(5, instance.count) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.count = value + ]: + jsonable['count'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.count', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..f05c04a43 --- /dev/null +++ b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,193 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_something_numbers_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.numbers = [0, value] + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.numbers[1]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.numbers[1]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.numbers[1]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_numbers_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.numbers = [0, value] + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_count_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.count = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.count', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.count', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.count', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_count_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.count = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/common.py b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/jsonization.py index d60ae2529..8d18b1de8 100644 --- a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/jsonization.py @@ -290,6 +290,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -297,11 +304,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -517,20 +544,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/verification.py index 7e04945b4..78540657b 100644 --- a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -100,7 +101,18 @@ 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: @@ -213,6 +225,34 @@ def transform_something( 'No number is seven' ) + for error in _verify_list_of__int(that.numbers): + error.path._prepend( + PropertySegment( + that, + 'numbers' + ) + ) + yield error + + for error in _verify_int(that.count): + error.path._prepend( + PropertySegment( + that, + 'count' + ) + ) + yield error + + if that.maybe_count is not None: + for error in _verify_int(that.maybe_count): + error.path._prepend( + PropertySegment( + that, + 'maybe_count' + ) + ) + yield error + _TRANSFORMER = _Transformer() @@ -229,5 +269,28 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + +def _verify_list_of__int( + that: Sequence[int] +) -> Iterator[Error]: + """Verify the items of :paramref:`that` recursively.""" + for i, item in enumerate(that): + for error in _verify_int(item): + error.path._prepend( + IndexSegment( + that, + i + ) + ) + yield error + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/xmlization.py index ceaa71add..b85ed6101 100644 --- a/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/int_length_interplay/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -819,7 +820,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -870,6 +872,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1181,6 +1192,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/int_parsing/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/int_parsing/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..bba140511 --- /dev/null +++ b/dev/test_data/main/python/expected/int_parsing/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,28 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import unittest + + +import dummy.common as our_common + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/int_parsing/expected_output/dummy/common.py b/dev/test_data/main/python/expected/int_parsing/expected_output/dummy/common.py index 4a8dc658a..f7a1ea92a 100644 --- a/dev/test_data/main/python/expected/int_parsing/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/int_parsing/expected_output/dummy/common.py @@ -103,6 +103,11 @@ def parse_safe_int(text: str) -> int: ``2 ** 53 - 1``, which a double-precision floating-point number represents exactly. + We check the safe range even though :py:class:`int` could hold larger + integers. TypeScript represents the integers as ``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. + :param text: to be parsed :return: parsed integer :raise: :py:class:`ValueError` if :paramref:`text` is not a safe integer @@ -122,23 +127,50 @@ def parse_safe_int(text: str) -> int: # NOTE (mristin): # We count the significant digits before we call ``int``, as the conversion # is quadratic in the length of the text, and Python refuses a text of more - # than 4300 digits, leading zeros included. + # than 4300 digits, leading zeros included. A safe integer has at most + # 16 significant digits. digits = text.lstrip("+-").lstrip("0") - if len(digits) > 16: + value = int(text) if len(digits) <= 16 else None + + if value is None or not is_safe_integer(value): raise ValueError( f"Expected a safe integer, but got a text out of its range: {text!r}" ) - value = int(digits) if len(digits) > 0 else 0 + return value - # NOTE (mristin): - # This is 2 ** 53 - 1. - if value > 9007199254740991: - raise ValueError( - f"Expected a safe integer, but got a text out of its range: {text!r}" - ) - return -value if text[0] == "-" else value +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 # This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_json_value_verification.py b/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_json_value_verification.py index f93fbfa2f..a3322a9c5 100644 --- a/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_json_value_verification.py +++ b/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_json_value_verification.py @@ -112,7 +112,7 @@ def test_neither_an_infinity_nor_a_not_a_number_is_json_able(self) -> None: ) def test_an_integer_is_json_able_while_it_fits_a_float(self) -> None: - for number in (0, 1, -3, 2 ** 53): + for number in (0, 1, -3, 2 ** 53 - 1, -(2 ** 53 - 1)): self.assertListEqual( [], _paths_and_causes( @@ -124,18 +124,19 @@ def test_an_integer_is_json_able_while_it_fits_a_float(self) -> None: def test_an_integer_too_large_for_a_float_is_refused(self) -> None: # NOTE (mristin): # JSON knows a single numeric type, so an ``int`` has to become - # a ``float``. 2**53 + 1 is the first whole number which a ``float`` - # can not tell from its neighbour, and 10**400 exceeds a ``float`` - # altogether. - for number in (2 ** 53 + 1, 10 ** 400): + # a ``float``. 2**53 is the first integer outside the safe integer + # range [-2^53 + 1, 2^53 - 1], as for the integer properties of + # the model, and 10**400 exceeds a ``float`` altogether. + for number in (2 ** 53, -(2 ** 53), 10 ** 400): paths_and_causes = _paths_and_causes( our_json_value_verification.verify_json_value([number]) ) self.assertEqual(1, len(paths_and_causes), f"for the number {number}") self.assertEqual("[0]", paths_and_causes[0][0]) - self.assertIn( - "not exactly representable as a JSON number", + self.assertEqual( + f"Expected a JSON-able value, but got the integer {number}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", paths_and_causes[0][1] ) diff --git a/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..e6097c66a --- /dev/null +++ b/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,254 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_json_able_integer_message(self) -> None: + self.assertEqual( + "Expected a JSON-able value, but got the integer -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_json_able_integer_message(-(2 ** 53)) + ) + + +class TestJsonAbleProperties(unittest.TestCase): + def test_something_value_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.value = [value] + + expected_cause = ( + f"Expected a JSON-able value, but got the integer {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.value[0]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.value[0]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.value[0]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_value_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.value = [value] + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_values_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.values = [value] + + expected_cause = ( + f"Expected a JSON-able value, but got the integer {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.values[0]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.values[0]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.values[0]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_values_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.values = [value] + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_mapping_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.mapping = {"a": value} + + expected_cause = ( + f"Expected a JSON-able value, but got the integer {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.mapping["a"]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.mapping["a"]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.mapping["a"]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_mapping_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.mapping = {"a": value} + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_xml_rpc.py b/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_xml_rpc.py index e98029df7..90460d5f0 100644 --- a/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_xml_rpc.py +++ b/dev/test_data/main/python/expected/json_types/expected_output/dev/tests/test_xml_rpc.py @@ -315,9 +315,9 @@ def test_the_path_of_a_failure_points_into_the_value(self) -> None: def test_an_integer_too_large_for_a_float_is_not_written(self) -> None: # NOTE (mristin): # A carries a JSON number, and a JSON number is a ``float``, - # so an ``int`` which no ``float`` can hold exactly has no - # to be written as. - for number in (2 ** 53 + 1, 10 ** 400): + # so an ``int`` outside the safe integer range [-2^53 + 1, 2^53 - 1] has + # no to be written as. + for number in (2 ** 53, -(2 ** 53), 10 ** 400): stream = io.StringIO() writer = our_xmlcommon.Writer(stream) @@ -327,11 +327,21 @@ def test_an_integer_too_large_for_a_float_is_not_written(self) -> None: ) as context: our_xmlrpc.write_discriminator(number, writer) - self.assertIn( - 'not exactly representable as a JSON number', + self.assertEqual( + f'Expected a JSON-able value, but got the integer {number}, ' + f'which is outside the safe integer range [-2^53 + 1, 2^53 - 1]', context.exception.cause ) + def test_a_safe_integer_is_written(self) -> None: + for number in (2 ** 53 - 1, -(2 ** 53 - 1)): + stream = io.StringIO() + writer = our_xmlcommon.Writer(stream) + + our_xmlrpc.write_discriminator(number, writer) + + self.assertIn(str(number), stream.getvalue()) + def test_a_key_which_is_no_string_stops_the_path(self) -> None: stream = io.StringIO() writer = our_xmlcommon.Writer(stream) diff --git a/dev/test_data/main/python/expected/json_types/expected_output/dummy/common.py b/dev/test_data/main/python/expected/json_types/expected_output/dummy/common.py index 3785285ad..e19c338ff 100644 --- a/dev/test_data/main/python/expected/json_types/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/json_types/expected_output/dummy/common.py @@ -85,43 +85,53 @@ def try_to_cast_to_array_like( return None -def try_to_convert_int_to_float( +def is_safe_integer( value: int -) -> Optional[float]: +) -> bool: """ - Try to convert :paramref:`value` to the ``float`` which JSON prescribes. + Check that :paramref:`value` lies in the safe integer range. - JSON knows a single numeric type, so an ``int`` has to become a ``float`` - on its way into and out of a JSON-able value. The conversion has to be - exact: a ``float`` which does not convert back to :paramref:`value` would - quietly say a different number than the one which was given, so such - a value is refused instead of rounded. + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. Mind that a ``bool`` is an ``int`` in Python. The callers all sort out the booleans before they come here. - >>> try_to_convert_int_to_float(1) - 1.0 + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True - >>> try_to_convert_int_to_float(-3) - -3.0 + >>> is_safe_integer(-(2 ** 53 - 1)) + True - >>> assert try_to_convert_int_to_float(2 ** 53 + 1) is None + >>> is_safe_integer(2 ** 53) + False - >>> assert try_to_convert_int_to_float(10 ** 400) is None + >>> is_safe_integer(-(2 ** 53)) + False """ - try: - result = float(value) - except OverflowError: - # NOTE (mristin): - # A Python ``int`` is unbounded, so one can be far too large for - # a ``float`` to hold at all. - return None + return -9007199254740991 <= value <= 9007199254740991 - if int(result) != value: - return None - return result +def unsafe_json_able_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` in a JSON-able value is not a safe integer. + + JSON knows a single numeric type, so an ``int`` has to become a ``float`` + on its way into and out of a JSON-able value. Only a safe integer survives + the conversion exactly, see :py:func:`is_safe_integer`. + """ + return ( + f"Expected a JSON-able value, but got the integer {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) # This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/python/expected/json_types/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/json_types/expected_output/dummy/jsonization.py index 891b9c432..2eabbc669 100644 --- a/dev/test_data/main/python/expected/json_types/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/json_types/expected_output/dummy/jsonization.py @@ -285,7 +285,7 @@ def _as_mapping( return jsonable -def _json_value_from_jsonable( +def json_value_from_jsonable( jsonable: Jsonable ) -> our_types.JsonValue: """ @@ -318,14 +318,12 @@ def _json_value_from_jsonable( # JSON knows a single numeric type, so :py:mod:`json` giving us # an ``int`` says nothing about the document: "1" and "1.0" are # the same number, and a JSON-able value holds it as a ``float``. - number = our_common.try_to_convert_int_to_float(jsonable) - if number is None: + if not our_common.is_safe_integer(jsonable): raise DeserializationException( - f"Expected a JSON-able value, but got the integer {jsonable}, " - f"which is not exactly representable as a JSON number" + our_common.unsafe_json_able_integer_message(jsonable) ) - return number + return float(jsonable) if isinstance(jsonable, float): # NOTE (mristin): @@ -353,7 +351,7 @@ def _json_value_from_jsonable( ) try: - mapping[key] = _json_value_from_jsonable(jsonable_value) + mapping[key] = json_value_from_jsonable(jsonable_value) except DeserializationException as exception: exception.path._prepend(KeySegment(jsonable, key)) raise @@ -366,7 +364,7 @@ def _json_value_from_jsonable( items: List[Any] = [] for i, jsonable_item in enumerate(array_like): try: - items.append(_json_value_from_jsonable(jsonable_item)) + items.append(json_value_from_jsonable(jsonable_item)) except DeserializationException as exception: exception.path._prepend(IndexSegment(array_like, i)) raise @@ -379,7 +377,7 @@ def _json_value_from_jsonable( ) -def _json_array_from_jsonable( +def json_array_from_jsonable( jsonable: Jsonable ) -> our_types.JsonArray: """ @@ -394,12 +392,12 @@ def _json_array_from_jsonable( f"Expected a JSON-able array, but got: {type(jsonable)}" ) - result = _json_value_from_jsonable(jsonable) + result = json_value_from_jsonable(jsonable) assert isinstance(result, list) return result -def _json_object_from_jsonable( +def json_object_from_jsonable( jsonable: Jsonable ) -> our_types.JsonObject: """ @@ -414,7 +412,7 @@ def _json_object_from_jsonable( f"Expected a JSON-able object, but got: {type(jsonable)}" ) - result = _json_value_from_jsonable(jsonable) + result = json_value_from_jsonable(jsonable) assert isinstance(result, dict) return result @@ -445,21 +443,19 @@ def something_from_jsonable( try: for key, jsonable_value in mapping.items(): if key == 'value': - the_value = _json_value_from_jsonable(jsonable_value) + the_value = json_value_from_jsonable(jsonable_value) elif key == 'values': - the_values = _json_array_from_jsonable(jsonable_value) + the_values = json_array_from_jsonable(jsonable_value) elif key == 'mapping': - the_mapping = _json_object_from_jsonable(jsonable_value) + the_mapping = json_object_from_jsonable(jsonable_value) elif key == 'mappingWithConstrainedKey': - the_mapping_with_constrained_key = ( - _json_object_from_jsonable(jsonable_value) - ) + the_mapping_with_constrained_key = json_object_from_jsonable(jsonable_value) elif key == 'optionalValue': - the_optional_value = _json_value_from_jsonable(jsonable_value) + the_optional_value = json_value_from_jsonable(jsonable_value) elif key == 'optionalValues': - the_optional_values = _json_array_from_jsonable(jsonable_value) + the_optional_values = json_array_from_jsonable(jsonable_value) elif key == 'optionalMapping': - the_optional_mapping = _json_object_from_jsonable(jsonable_value) + the_optional_mapping = json_object_from_jsonable(jsonable_value) else: unexpected_key = key break @@ -562,7 +558,7 @@ def __str__(self) -> str: return f'{self.path}: {self.cause}' -def _json_value_to_jsonable( +def json_value_to_jsonable( value: our_types.JsonValue ) -> MutableJsonable: """ @@ -592,13 +588,12 @@ def _json_value_to_jsonable( if isinstance(value, int): # NOTE (mristin): # An ``int`` is written out as it stands. JSON knows a single - # numeric type, though, so it has to be one which a ``float`` can - # hold exactly -- otherwise a reader, which reads every number as - # a ``float``, would get a different number back. - if our_common.try_to_convert_int_to_float(value) is None: + # numeric type, though, so it has to lie in the safe integer range + # [-2^53 + 1, 2^53 - 1] -- otherwise a reader, which reads every number + # as a ``float``, might get a different number back. + if not our_common.is_safe_integer(value): raise SerializationException( - f"Expected a JSON-able value, but got the integer {value}, " - f"which is not exactly representable as a JSON number" + our_common.unsafe_json_able_integer_message(value) ) return value @@ -625,7 +620,7 @@ def _json_value_to_jsonable( ) try: - mapping[key] = _json_value_to_jsonable(item_value) + mapping[key] = json_value_to_jsonable(item_value) except SerializationException as exception: exception._prepend_key(key) raise @@ -638,7 +633,7 @@ def _json_value_to_jsonable( items: List[Any] = [] for i, item_value in enumerate(array_like): try: - items.append(_json_value_to_jsonable(item_value)) + items.append(json_value_to_jsonable(item_value)) except SerializationException as exception: exception._prepend_index(i) raise @@ -657,28 +652,28 @@ def _something_to_jsonable( """Serialize :paramref:`that` to a JSON-able representation.""" jsonable: MutableMapping[str, MutableJsonable] = {} try: - jsonable['value'] = _json_value_to_jsonable( + jsonable['value'] = json_value_to_jsonable( that.value ) except SerializationException as exception: exception._prepend_property('value') raise try: - jsonable['values'] = _json_value_to_jsonable( + jsonable['values'] = json_value_to_jsonable( that.values ) except SerializationException as exception: exception._prepend_property('values') raise try: - jsonable['mapping'] = _json_value_to_jsonable( + jsonable['mapping'] = json_value_to_jsonable( that.mapping ) except SerializationException as exception: exception._prepend_property('mapping') raise try: - jsonable['mappingWithConstrainedKey'] = _json_value_to_jsonable( + jsonable['mappingWithConstrainedKey'] = json_value_to_jsonable( that.mapping_with_constrained_key ) except SerializationException as exception: @@ -686,7 +681,7 @@ def _something_to_jsonable( raise if that.optional_value is not None: try: - jsonable['optionalValue'] = _json_value_to_jsonable( + jsonable['optionalValue'] = json_value_to_jsonable( that.optional_value ) except SerializationException as exception: @@ -694,7 +689,7 @@ def _something_to_jsonable( raise if that.optional_values is not None: try: - jsonable['optionalValues'] = _json_value_to_jsonable( + jsonable['optionalValues'] = json_value_to_jsonable( that.optional_values ) except SerializationException as exception: @@ -702,7 +697,7 @@ def _something_to_jsonable( raise if that.optional_mapping is not None: try: - jsonable['optionalMapping'] = _json_value_to_jsonable( + jsonable['optionalMapping'] = json_value_to_jsonable( that.optional_mapping ) except SerializationException as exception: diff --git a/dev/test_data/main/python/expected/json_types/expected_output/dummy/jsonvalueverification.py b/dev/test_data/main/python/expected/json_types/expected_output/dummy/jsonvalueverification.py index 0f3360f10..3b79d8420 100644 --- a/dev/test_data/main/python/expected/json_types/expected_output/dummy/jsonvalueverification.py +++ b/dev/test_data/main/python/expected/json_types/expected_output/dummy/jsonvalueverification.py @@ -63,12 +63,9 @@ def verify_json_value( # JSON knows a single numeric type, so an ``int`` becomes a ``float`` # on the way out. Only an ``int`` which that conversion can not carry # exactly is an error here -- see - # :py:func:`.common.try_to_convert_int_to_float`. - if our_common.try_to_convert_int_to_float(value) is None: - yield Error( - f"Expected a JSON-able value, but got the integer {value}, " - f"which is not exactly representable as a JSON number" - ) + # :py:func:`.common.is_safe_integer`. + if not our_common.is_safe_integer(value): + yield Error(our_common.unsafe_json_able_integer_message(value)) return diff --git a/dev/test_data/main/python/expected/json_types/expected_output/dummy/xmlrpc.py b/dev/test_data/main/python/expected/json_types/expected_output/dummy/xmlrpc.py index 489ee824e..a8b39e8bf 100644 --- a/dev/test_data/main/python/expected/json_types/expected_output/dummy/xmlrpc.py +++ b/dev/test_data/main/python/expected/json_types/expected_output/dummy/xmlrpc.py @@ -483,13 +483,12 @@ def write_discriminator( # NOTE (mristin): # An ``int`` is written out as it stands, free of the fraction which # a ``float`` carries. JSON knows a single numeric type, though, so - # it has to be one which a ``float`` can hold exactly -- otherwise - # a reader, which reads every as a ``float``, would get + # it has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] -- otherwise + # a reader, which reads every as a ``float``, might get # a different number back. - if our_common.try_to_convert_int_to_float(value) is None: + if not our_common.is_safe_integer(value): raise SerializationException( - f"Expected a JSON-able value, but got the integer {value}, " - f"which is not exactly representable as a JSON number" + our_common.unsafe_json_able_integer_message(value) ) writer.write_start_element_in_no_namespace('double') diff --git a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_json_value_verification.py b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_json_value_verification.py index f93fbfa2f..a3322a9c5 100644 --- a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_json_value_verification.py +++ b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_json_value_verification.py @@ -112,7 +112,7 @@ def test_neither_an_infinity_nor_a_not_a_number_is_json_able(self) -> None: ) def test_an_integer_is_json_able_while_it_fits_a_float(self) -> None: - for number in (0, 1, -3, 2 ** 53): + for number in (0, 1, -3, 2 ** 53 - 1, -(2 ** 53 - 1)): self.assertListEqual( [], _paths_and_causes( @@ -124,18 +124,19 @@ def test_an_integer_is_json_able_while_it_fits_a_float(self) -> None: def test_an_integer_too_large_for_a_float_is_refused(self) -> None: # NOTE (mristin): # JSON knows a single numeric type, so an ``int`` has to become - # a ``float``. 2**53 + 1 is the first whole number which a ``float`` - # can not tell from its neighbour, and 10**400 exceeds a ``float`` - # altogether. - for number in (2 ** 53 + 1, 10 ** 400): + # a ``float``. 2**53 is the first integer outside the safe integer + # range [-2^53 + 1, 2^53 - 1], as for the integer properties of + # the model, and 10**400 exceeds a ``float`` altogether. + for number in (2 ** 53, -(2 ** 53), 10 ** 400): paths_and_causes = _paths_and_causes( our_json_value_verification.verify_json_value([number]) ) self.assertEqual(1, len(paths_and_causes), f"for the number {number}") self.assertEqual("[0]", paths_and_causes[0][0]) - self.assertIn( - "not exactly representable as a JSON number", + self.assertEqual( + f"Expected a JSON-able value, but got the integer {number}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", paths_and_causes[0][1] ) diff --git a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..34b718b84 --- /dev/null +++ b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,35 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import unittest + + +import dummy.common as our_common + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_json_able_integer_message(self) -> None: + self.assertEqual( + "Expected a JSON-able value, but got the integer -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_json_able_integer_message(-(2 ** 53)) + ) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_xml_rpc.py b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_xml_rpc.py index e98029df7..90460d5f0 100644 --- a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_xml_rpc.py +++ b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dev/tests/test_xml_rpc.py @@ -315,9 +315,9 @@ def test_the_path_of_a_failure_points_into_the_value(self) -> None: def test_an_integer_too_large_for_a_float_is_not_written(self) -> None: # NOTE (mristin): # A carries a JSON number, and a JSON number is a ``float``, - # so an ``int`` which no ``float`` can hold exactly has no - # to be written as. - for number in (2 ** 53 + 1, 10 ** 400): + # so an ``int`` outside the safe integer range [-2^53 + 1, 2^53 - 1] has + # no to be written as. + for number in (2 ** 53, -(2 ** 53), 10 ** 400): stream = io.StringIO() writer = our_xmlcommon.Writer(stream) @@ -327,11 +327,21 @@ def test_an_integer_too_large_for_a_float_is_not_written(self) -> None: ) as context: our_xmlrpc.write_discriminator(number, writer) - self.assertIn( - 'not exactly representable as a JSON number', + self.assertEqual( + f'Expected a JSON-able value, but got the integer {number}, ' + f'which is outside the safe integer range [-2^53 + 1, 2^53 - 1]', context.exception.cause ) + def test_a_safe_integer_is_written(self) -> None: + for number in (2 ** 53 - 1, -(2 ** 53 - 1)): + stream = io.StringIO() + writer = our_xmlcommon.Writer(stream) + + our_xmlrpc.write_discriminator(number, writer) + + self.assertIn(str(number), stream.getvalue()) + def test_a_key_which_is_no_string_stops_the_path(self) -> None: stream = io.StringIO() writer = our_xmlcommon.Writer(stream) diff --git a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/common.py b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/common.py index 3785285ad..e19c338ff 100644 --- a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/common.py @@ -85,43 +85,53 @@ def try_to_cast_to_array_like( return None -def try_to_convert_int_to_float( +def is_safe_integer( value: int -) -> Optional[float]: +) -> bool: """ - Try to convert :paramref:`value` to the ``float`` which JSON prescribes. + Check that :paramref:`value` lies in the safe integer range. - JSON knows a single numeric type, so an ``int`` has to become a ``float`` - on its way into and out of a JSON-able value. The conversion has to be - exact: a ``float`` which does not convert back to :paramref:`value` would - quietly say a different number than the one which was given, so such - a value is refused instead of rounded. + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. Mind that a ``bool`` is an ``int`` in Python. The callers all sort out the booleans before they come here. - >>> try_to_convert_int_to_float(1) - 1.0 + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True - >>> try_to_convert_int_to_float(-3) - -3.0 + >>> is_safe_integer(-(2 ** 53 - 1)) + True - >>> assert try_to_convert_int_to_float(2 ** 53 + 1) is None + >>> is_safe_integer(2 ** 53) + False - >>> assert try_to_convert_int_to_float(10 ** 400) is None + >>> is_safe_integer(-(2 ** 53)) + False """ - try: - result = float(value) - except OverflowError: - # NOTE (mristin): - # A Python ``int`` is unbounded, so one can be far too large for - # a ``float`` to hold at all. - return None + return -9007199254740991 <= value <= 9007199254740991 - if int(result) != value: - return None - return result +def unsafe_json_able_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` in a JSON-able value is not a safe integer. + + JSON knows a single numeric type, so an ``int`` has to become a ``float`` + on its way into and out of a JSON-able value. Only a safe integer survives + the conversion exactly, see :py:func:`is_safe_integer`. + """ + return ( + f"Expected a JSON-able value, but got the integer {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) # This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/jsonization.py index ebb02eb39..ed64c3857 100644 --- a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/jsonization.py @@ -334,7 +334,7 @@ def _list_from_jsonable( return result -def _json_value_from_jsonable( +def json_value_from_jsonable( jsonable: Jsonable ) -> our_types.JsonValue: """ @@ -367,14 +367,12 @@ def _json_value_from_jsonable( # JSON knows a single numeric type, so :py:mod:`json` giving us # an ``int`` says nothing about the document: "1" and "1.0" are # the same number, and a JSON-able value holds it as a ``float``. - number = our_common.try_to_convert_int_to_float(jsonable) - if number is None: + if not our_common.is_safe_integer(jsonable): raise DeserializationException( - f"Expected a JSON-able value, but got the integer {jsonable}, " - f"which is not exactly representable as a JSON number" + our_common.unsafe_json_able_integer_message(jsonable) ) - return number + return float(jsonable) if isinstance(jsonable, float): # NOTE (mristin): @@ -402,7 +400,7 @@ def _json_value_from_jsonable( ) try: - mapping[key] = _json_value_from_jsonable(jsonable_value) + mapping[key] = json_value_from_jsonable(jsonable_value) except DeserializationException as exception: exception.path._prepend(KeySegment(jsonable, key)) raise @@ -415,7 +413,7 @@ def _json_value_from_jsonable( items: List[Any] = [] for i, jsonable_item in enumerate(array_like): try: - items.append(_json_value_from_jsonable(jsonable_item)) + items.append(json_value_from_jsonable(jsonable_item)) except DeserializationException as exception: exception.path._prepend(IndexSegment(array_like, i)) raise @@ -428,7 +426,7 @@ def _json_value_from_jsonable( ) -def _json_array_from_jsonable( +def json_array_from_jsonable( jsonable: Jsonable ) -> our_types.JsonArray: """ @@ -443,12 +441,12 @@ def _json_array_from_jsonable( f"Expected a JSON-able array, but got: {type(jsonable)}" ) - result = _json_value_from_jsonable(jsonable) + result = json_value_from_jsonable(jsonable) assert isinstance(result, list) return result -def _json_object_from_jsonable( +def json_object_from_jsonable( jsonable: Jsonable ) -> our_types.JsonObject: """ @@ -463,7 +461,7 @@ def _json_object_from_jsonable( f"Expected a JSON-able object, but got: {type(jsonable)}" ) - result = _json_value_from_jsonable(jsonable) + result = json_value_from_jsonable(jsonable) assert isinstance(result, dict) return result @@ -560,7 +558,7 @@ def _list_of__json_object_from_jsonable( """ return _list_from_jsonable( jsonable, - _json_object_from_jsonable + json_object_from_jsonable ) @@ -577,7 +575,7 @@ def _list_of__json_value_from_jsonable( """ return _list_from_jsonable( jsonable, - _json_value_from_jsonable + json_value_from_jsonable ) @@ -594,9 +592,9 @@ def _tuple4_of__str__json_value__json_array__json_object_from_jsonable( return _tuple4_from_jsonable( jsonable, _str_from_jsonable, - _json_value_from_jsonable, - _json_array_from_jsonable, - _json_object_from_jsonable + json_value_from_jsonable, + json_array_from_jsonable, + json_object_from_jsonable ) @@ -638,7 +636,7 @@ def something_from_jsonable( elif key == 'optionalObjects': the_optional_objects = _list_of__json_object_from_jsonable(jsonable_value) elif key == 'optionalObject': - the_optional_object = _json_object_from_jsonable(jsonable_value) + the_optional_object = json_object_from_jsonable(jsonable_value) else: unexpected_key = key break @@ -728,7 +726,7 @@ def __str__(self) -> str: return f'{self.path}: {self.cause}' -def _json_value_to_jsonable( +def json_value_to_jsonable( value: our_types.JsonValue ) -> MutableJsonable: """ @@ -758,13 +756,12 @@ def _json_value_to_jsonable( if isinstance(value, int): # NOTE (mristin): # An ``int`` is written out as it stands. JSON knows a single - # numeric type, though, so it has to be one which a ``float`` can - # hold exactly -- otherwise a reader, which reads every number as - # a ``float``, would get a different number back. - if our_common.try_to_convert_int_to_float(value) is None: + # numeric type, though, so it has to lie in the safe integer range + # [-2^53 + 1, 2^53 - 1] -- otherwise a reader, which reads every number + # as a ``float``, might get a different number back. + if not our_common.is_safe_integer(value): raise SerializationException( - f"Expected a JSON-able value, but got the integer {value}, " - f"which is not exactly representable as a JSON number" + our_common.unsafe_json_able_integer_message(value) ) return value @@ -791,7 +788,7 @@ def _json_value_to_jsonable( ) try: - mapping[key] = _json_value_to_jsonable(item_value) + mapping[key] = json_value_to_jsonable(item_value) except SerializationException as exception: exception._prepend_key(key) raise @@ -804,7 +801,7 @@ def _json_value_to_jsonable( items: List[Any] = [] for i, item_value in enumerate(array_like): try: - items.append(_json_value_to_jsonable(item_value)) + items.append(json_value_to_jsonable(item_value)) except SerializationException as exception: exception._prepend_index(i) raise @@ -831,7 +828,7 @@ def _list_of__json_object_to_jsonable( for i, item in enumerate(that): try: jsonable.append( - _json_value_to_jsonable( + json_value_to_jsonable( item ) ) @@ -855,7 +852,7 @@ def _list_of__json_value_to_jsonable( for i, item in enumerate(that): try: jsonable.append( - _json_value_to_jsonable( + json_value_to_jsonable( item ) ) @@ -889,7 +886,7 @@ def _tuple4_of__str__json_value__json_array__json_object_to_jsonable( raise try: jsonable.append( - _json_value_to_jsonable( + json_value_to_jsonable( that[1] ) ) @@ -898,7 +895,7 @@ def _tuple4_of__str__json_value__json_array__json_object_to_jsonable( raise try: jsonable.append( - _json_value_to_jsonable( + json_value_to_jsonable( that[2] ) ) @@ -907,7 +904,7 @@ def _tuple4_of__str__json_value__json_array__json_object_to_jsonable( raise try: jsonable.append( - _json_value_to_jsonable( + json_value_to_jsonable( that[3] ) ) @@ -946,7 +943,7 @@ def _something_to_jsonable( raise if that.optional_object is not None: try: - jsonable['optionalObject'] = _json_value_to_jsonable( + jsonable['optionalObject'] = json_value_to_jsonable( that.optional_object ) except SerializationException as exception: diff --git a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/jsonvalueverification.py b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/jsonvalueverification.py index 0f3360f10..3b79d8420 100644 --- a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/jsonvalueverification.py +++ b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/jsonvalueverification.py @@ -63,12 +63,9 @@ def verify_json_value( # JSON knows a single numeric type, so an ``int`` becomes a ``float`` # on the way out. Only an ``int`` which that conversion can not carry # exactly is an error here -- see - # :py:func:`.common.try_to_convert_int_to_float`. - if our_common.try_to_convert_int_to_float(value) is None: - yield Error( - f"Expected a JSON-able value, but got the integer {value}, " - f"which is not exactly representable as a JSON number" - ) + # :py:func:`.common.is_safe_integer`. + if not our_common.is_safe_integer(value): + yield Error(our_common.unsafe_json_able_integer_message(value)) return diff --git a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/xmlrpc.py b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/xmlrpc.py index 489ee824e..a8b39e8bf 100644 --- a/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/xmlrpc.py +++ b/dev/test_data/main/python/expected/json_types_in_collections/expected_output/dummy/xmlrpc.py @@ -483,13 +483,12 @@ def write_discriminator( # NOTE (mristin): # An ``int`` is written out as it stands, free of the fraction which # a ``float`` carries. JSON knows a single numeric type, though, so - # it has to be one which a ``float`` can hold exactly -- otherwise - # a reader, which reads every as a ``float``, would get + # it has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] -- otherwise + # a reader, which reads every as a ``float``, might get # a different number back. - if our_common.try_to_convert_int_to_float(value) is None: + if not our_common.is_safe_integer(value): raise SerializationException( - f"Expected a JSON-able value, but got the integer {value}, " - f"which is not exactly representable as a JSON number" + our_common.unsafe_json_able_integer_message(value) ) writer.write_start_element_in_no_namespace('double') diff --git a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_json_value_verification.py b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_json_value_verification.py index f93fbfa2f..a3322a9c5 100644 --- a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_json_value_verification.py +++ b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_json_value_verification.py @@ -112,7 +112,7 @@ def test_neither_an_infinity_nor_a_not_a_number_is_json_able(self) -> None: ) def test_an_integer_is_json_able_while_it_fits_a_float(self) -> None: - for number in (0, 1, -3, 2 ** 53): + for number in (0, 1, -3, 2 ** 53 - 1, -(2 ** 53 - 1)): self.assertListEqual( [], _paths_and_causes( @@ -124,18 +124,19 @@ def test_an_integer_is_json_able_while_it_fits_a_float(self) -> None: def test_an_integer_too_large_for_a_float_is_refused(self) -> None: # NOTE (mristin): # JSON knows a single numeric type, so an ``int`` has to become - # a ``float``. 2**53 + 1 is the first whole number which a ``float`` - # can not tell from its neighbour, and 10**400 exceeds a ``float`` - # altogether. - for number in (2 ** 53 + 1, 10 ** 400): + # a ``float``. 2**53 is the first integer outside the safe integer + # range [-2^53 + 1, 2^53 - 1], as for the integer properties of + # the model, and 10**400 exceeds a ``float`` altogether. + for number in (2 ** 53, -(2 ** 53), 10 ** 400): paths_and_causes = _paths_and_causes( our_json_value_verification.verify_json_value([number]) ) self.assertEqual(1, len(paths_and_causes), f"for the number {number}") self.assertEqual("[0]", paths_and_causes[0][0]) - self.assertIn( - "not exactly representable as a JSON number", + self.assertEqual( + f"Expected a JSON-able value, but got the integer {number}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", paths_and_causes[0][1] ) diff --git a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..6aa095ca5 --- /dev/null +++ b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,193 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_json_able_integer_message(self) -> None: + self.assertEqual( + "Expected a JSON-able value, but got the integer -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_json_able_integer_message(-(2 ** 53)) + ) + + +class TestJsonAbleProperties(unittest.TestCase): + def test_something_mapping_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.mapping = {"a": value} + + expected_cause = ( + f"Expected a JSON-able value, but got the integer {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.mapping["a"]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.mapping["a"]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.mapping["a"]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_mapping_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.mapping = {"a": value} + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_values_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.values = [value] + + expected_cause = ( + f"Expected a JSON-able value, but got the integer {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.values[0]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.values[0]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.values[0]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_values_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.values = [value] + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_xml_rpc.py b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_xml_rpc.py index e98029df7..90460d5f0 100644 --- a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_xml_rpc.py +++ b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dev/tests/test_xml_rpc.py @@ -315,9 +315,9 @@ def test_the_path_of_a_failure_points_into_the_value(self) -> None: def test_an_integer_too_large_for_a_float_is_not_written(self) -> None: # NOTE (mristin): # A carries a JSON number, and a JSON number is a ``float``, - # so an ``int`` which no ``float`` can hold exactly has no - # to be written as. - for number in (2 ** 53 + 1, 10 ** 400): + # so an ``int`` outside the safe integer range [-2^53 + 1, 2^53 - 1] has + # no to be written as. + for number in (2 ** 53, -(2 ** 53), 10 ** 400): stream = io.StringIO() writer = our_xmlcommon.Writer(stream) @@ -327,11 +327,21 @@ def test_an_integer_too_large_for_a_float_is_not_written(self) -> None: ) as context: our_xmlrpc.write_discriminator(number, writer) - self.assertIn( - 'not exactly representable as a JSON number', + self.assertEqual( + f'Expected a JSON-able value, but got the integer {number}, ' + f'which is outside the safe integer range [-2^53 + 1, 2^53 - 1]', context.exception.cause ) + def test_a_safe_integer_is_written(self) -> None: + for number in (2 ** 53 - 1, -(2 ** 53 - 1)): + stream = io.StringIO() + writer = our_xmlcommon.Writer(stream) + + our_xmlrpc.write_discriminator(number, writer) + + self.assertIn(str(number), stream.getvalue()) + def test_a_key_which_is_no_string_stops_the_path(self) -> None: stream = io.StringIO() writer = our_xmlcommon.Writer(stream) diff --git a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/common.py b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/common.py index 3785285ad..e19c338ff 100644 --- a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/common.py @@ -85,43 +85,53 @@ def try_to_cast_to_array_like( return None -def try_to_convert_int_to_float( +def is_safe_integer( value: int -) -> Optional[float]: +) -> bool: """ - Try to convert :paramref:`value` to the ``float`` which JSON prescribes. + Check that :paramref:`value` lies in the safe integer range. - JSON knows a single numeric type, so an ``int`` has to become a ``float`` - on its way into and out of a JSON-able value. The conversion has to be - exact: a ``float`` which does not convert back to :paramref:`value` would - quietly say a different number than the one which was given, so such - a value is refused instead of rounded. + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. Mind that a ``bool`` is an ``int`` in Python. The callers all sort out the booleans before they come here. - >>> try_to_convert_int_to_float(1) - 1.0 + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True - >>> try_to_convert_int_to_float(-3) - -3.0 + >>> is_safe_integer(-(2 ** 53 - 1)) + True - >>> assert try_to_convert_int_to_float(2 ** 53 + 1) is None + >>> is_safe_integer(2 ** 53) + False - >>> assert try_to_convert_int_to_float(10 ** 400) is None + >>> is_safe_integer(-(2 ** 53)) + False """ - try: - result = float(value) - except OverflowError: - # NOTE (mristin): - # A Python ``int`` is unbounded, so one can be far too large for - # a ``float`` to hold at all. - return None + return -9007199254740991 <= value <= 9007199254740991 - if int(result) != value: - return None - return result +def unsafe_json_able_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` in a JSON-able value is not a safe integer. + + JSON knows a single numeric type, so an ``int`` has to become a ``float`` + on its way into and out of a JSON-able value. Only a safe integer survives + the conversion exactly, see :py:func:`is_safe_integer`. + """ + return ( + f"Expected a JSON-able value, but got the integer {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) # This code has been automatically generated by aas-core-codegen. diff --git a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/jsonization.py index a6d0035dc..df99a7b9f 100644 --- a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/jsonization.py @@ -285,7 +285,7 @@ def _as_mapping( return jsonable -def _json_value_from_jsonable( +def json_value_from_jsonable( jsonable: Jsonable ) -> our_types.JsonValue: """ @@ -318,14 +318,12 @@ def _json_value_from_jsonable( # JSON knows a single numeric type, so :py:mod:`json` giving us # an ``int`` says nothing about the document: "1" and "1.0" are # the same number, and a JSON-able value holds it as a ``float``. - number = our_common.try_to_convert_int_to_float(jsonable) - if number is None: + if not our_common.is_safe_integer(jsonable): raise DeserializationException( - f"Expected a JSON-able value, but got the integer {jsonable}, " - f"which is not exactly representable as a JSON number" + our_common.unsafe_json_able_integer_message(jsonable) ) - return number + return float(jsonable) if isinstance(jsonable, float): # NOTE (mristin): @@ -353,7 +351,7 @@ def _json_value_from_jsonable( ) try: - mapping[key] = _json_value_from_jsonable(jsonable_value) + mapping[key] = json_value_from_jsonable(jsonable_value) except DeserializationException as exception: exception.path._prepend(KeySegment(jsonable, key)) raise @@ -366,7 +364,7 @@ def _json_value_from_jsonable( items: List[Any] = [] for i, jsonable_item in enumerate(array_like): try: - items.append(_json_value_from_jsonable(jsonable_item)) + items.append(json_value_from_jsonable(jsonable_item)) except DeserializationException as exception: exception.path._prepend(IndexSegment(array_like, i)) raise @@ -379,7 +377,7 @@ def _json_value_from_jsonable( ) -def _json_array_from_jsonable( +def json_array_from_jsonable( jsonable: Jsonable ) -> our_types.JsonArray: """ @@ -394,12 +392,12 @@ def _json_array_from_jsonable( f"Expected a JSON-able array, but got: {type(jsonable)}" ) - result = _json_value_from_jsonable(jsonable) + result = json_value_from_jsonable(jsonable) assert isinstance(result, list) return result -def _json_object_from_jsonable( +def json_object_from_jsonable( jsonable: Jsonable ) -> our_types.JsonObject: """ @@ -414,7 +412,7 @@ def _json_object_from_jsonable( f"Expected a JSON-able object, but got: {type(jsonable)}" ) - result = _json_value_from_jsonable(jsonable) + result = json_value_from_jsonable(jsonable) assert isinstance(result, dict) return result @@ -442,13 +440,13 @@ def something_from_jsonable( try: for key, jsonable_value in mapping.items(): if key == 'mapping': - the_mapping = _json_object_from_jsonable(jsonable_value) + the_mapping = json_object_from_jsonable(jsonable_value) elif key == 'values': - the_values = _json_array_from_jsonable(jsonable_value) + the_values = json_array_from_jsonable(jsonable_value) elif key == 'optionalMapping': - the_optional_mapping = _json_object_from_jsonable(jsonable_value) + the_optional_mapping = json_object_from_jsonable(jsonable_value) elif key == 'optionalValue': - the_optional_value = _json_value_from_jsonable(jsonable_value) + the_optional_value = json_value_from_jsonable(jsonable_value) else: unexpected_key = key break @@ -538,7 +536,7 @@ def __str__(self) -> str: return f'{self.path}: {self.cause}' -def _json_value_to_jsonable( +def json_value_to_jsonable( value: our_types.JsonValue ) -> MutableJsonable: """ @@ -568,13 +566,12 @@ def _json_value_to_jsonable( if isinstance(value, int): # NOTE (mristin): # An ``int`` is written out as it stands. JSON knows a single - # numeric type, though, so it has to be one which a ``float`` can - # hold exactly -- otherwise a reader, which reads every number as - # a ``float``, would get a different number back. - if our_common.try_to_convert_int_to_float(value) is None: + # numeric type, though, so it has to lie in the safe integer range + # [-2^53 + 1, 2^53 - 1] -- otherwise a reader, which reads every number + # as a ``float``, might get a different number back. + if not our_common.is_safe_integer(value): raise SerializationException( - f"Expected a JSON-able value, but got the integer {value}, " - f"which is not exactly representable as a JSON number" + our_common.unsafe_json_able_integer_message(value) ) return value @@ -601,7 +598,7 @@ def _json_value_to_jsonable( ) try: - mapping[key] = _json_value_to_jsonable(item_value) + mapping[key] = json_value_to_jsonable(item_value) except SerializationException as exception: exception._prepend_key(key) raise @@ -614,7 +611,7 @@ def _json_value_to_jsonable( items: List[Any] = [] for i, item_value in enumerate(array_like): try: - items.append(_json_value_to_jsonable(item_value)) + items.append(json_value_to_jsonable(item_value)) except SerializationException as exception: exception._prepend_index(i) raise @@ -633,14 +630,14 @@ def _something_to_jsonable( """Serialize :paramref:`that` to a JSON-able representation.""" jsonable: MutableMapping[str, MutableJsonable] = {} try: - jsonable['mapping'] = _json_value_to_jsonable( + jsonable['mapping'] = json_value_to_jsonable( that.mapping ) except SerializationException as exception: exception._prepend_property('mapping') raise try: - jsonable['values'] = _json_value_to_jsonable( + jsonable['values'] = json_value_to_jsonable( that.values ) except SerializationException as exception: @@ -648,7 +645,7 @@ def _something_to_jsonable( raise if that.optional_mapping is not None: try: - jsonable['optionalMapping'] = _json_value_to_jsonable( + jsonable['optionalMapping'] = json_value_to_jsonable( that.optional_mapping ) except SerializationException as exception: @@ -656,7 +653,7 @@ def _something_to_jsonable( raise if that.optional_value is not None: try: - jsonable['optionalValue'] = _json_value_to_jsonable( + jsonable['optionalValue'] = json_value_to_jsonable( that.optional_value ) except SerializationException as exception: diff --git a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/jsonvalueverification.py b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/jsonvalueverification.py index 0f3360f10..3b79d8420 100644 --- a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/jsonvalueverification.py +++ b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/jsonvalueverification.py @@ -63,12 +63,9 @@ def verify_json_value( # JSON knows a single numeric type, so an ``int`` becomes a ``float`` # on the way out. Only an ``int`` which that conversion can not carry # exactly is an error here -- see - # :py:func:`.common.try_to_convert_int_to_float`. - if our_common.try_to_convert_int_to_float(value) is None: - yield Error( - f"Expected a JSON-able value, but got the integer {value}, " - f"which is not exactly representable as a JSON number" - ) + # :py:func:`.common.is_safe_integer`. + if not our_common.is_safe_integer(value): + yield Error(our_common.unsafe_json_able_integer_message(value)) return diff --git a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/xmlrpc.py b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/xmlrpc.py index 489ee824e..a8b39e8bf 100644 --- a/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/xmlrpc.py +++ b/dev/test_data/main/python/expected/json_types_in_invariants/expected_output/dummy/xmlrpc.py @@ -483,13 +483,12 @@ def write_discriminator( # NOTE (mristin): # An ``int`` is written out as it stands, free of the fraction which # a ``float`` carries. JSON knows a single numeric type, though, so - # it has to be one which a ``float`` can hold exactly -- otherwise - # a reader, which reads every as a ``float``, would get + # it has to lie in the safe integer range [-2^53 + 1, 2^53 - 1] -- otherwise + # a reader, which reads every as a ``float``, might get # a different number back. - if our_common.try_to_convert_int_to_float(value) is None: + if not our_common.is_safe_integer(value): raise SerializationException( - f"Expected a JSON-able value, but got the integer {value}, " - f"which is not exactly representable as a JSON number" + our_common.unsafe_json_able_integer_message(value) ) writer.write_start_element_in_no_namespace('double') diff --git a/dev/test_data/main/python/expected/list_of_classes/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/list_of_classes/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 3ed63242c..46e706624 100644 --- a/dev/test_data/main/python/expected/list_of_classes/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/list_of_classes/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -134,23 +134,48 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_another_item_serial_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.another_item_from_jsonable( - _load_the_first_expected('AnotherItem') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_another_item_serial_number(self) -> None: + jsonable = _load_the_first_expected('AnotherItem') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['serialNumber'] = 5.0 + instance = our_jsonization.another_item_from_jsonable(jsonable) + self.assertIs(int, type(instance.serial_number)) + self.assertEqual(5, instance.serial_number) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.serial_number = value + ]: + jsonable['serialNumber'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.another_item_from_jsonable(jsonable) self.assertEqual( - '.serial_number', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/list_of_classes/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/list_of_classes/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..dca865a07 --- /dev/null +++ b/dev/test_data/main/python/expected/list_of_classes/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_another_item_serial_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.another_item_from_jsonable( + _load_the_first_expected('AnotherItem') + ) + + instance.serial_number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.serial_number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.serial_number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.serial_number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_another_item_serial_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.another_item_from_jsonable( + _load_the_first_expected('AnotherItem') + ) + + instance.serial_number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/common.py b/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/jsonization.py index 81c6f2ce3..57a8fd2ac 100644 --- a/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/jsonization.py @@ -332,6 +332,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -339,11 +346,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -761,20 +788,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/verification.py index bdb3879e7..cd7e4f866 100644 --- a/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -149,12 +150,14 @@ def transform_another_item( self, that: our_types.AnotherItem ) -> Iterator[Error]: - # No verification has been defined for AnotherItem. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.serial_number): + error.path._prepend( + PropertySegment( + that, + 'serial_number' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_simple( @@ -258,6 +261,14 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + def _verify_list_of__class( that: Sequence[our_types.Class] ) -> Iterator[Error]: diff --git a/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/xmlization.py index a9bc7ad14..3e2e61dc8 100644 --- a/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/list_of_classes/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -1535,7 +1536,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1586,6 +1588,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -2110,6 +2121,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 99abb7399..bb4fc004c 100644 --- a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -64,24 +64,6 @@ def _load_the_first_expected(model_type: str) -> Any: class TestSerializationFailures(unittest.TestCase): - def test_something_some_ints_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) - - instance.some_ints = [0, value] - - with self.assertRaises( - our_jsonization.SerializationException - ) as context_manager: - our_jsonization.to_jsonable(instance) - - self.assertEqual( - '.some_ints[1]', - context_manager.exception.path - ) - def test_something_some_floats_non_finite(self) -> None: for value in [math.inf, -math.inf, math.nan]: instance = our_jsonization.something_from_jsonable( diff --git a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..896055056 --- /dev/null +++ b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_something_some_ints_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.some_ints = [0, value] + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.some_ints[1]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.some_ints[1]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.some_ints[1]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_some_ints_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.some_ints = [0, value] + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/common.py b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/jsonization.py index 474c48164..7f7d4ee71 100644 --- a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/jsonization.py @@ -309,6 +309,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -316,11 +323,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _float_from_jsonable( @@ -663,20 +690,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/verification.py index 48e0624fb..5e3018823 100644 --- a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -268,6 +269,15 @@ def transform_something( 'Some floats must be unique.' ) + for error in _verify_list_of__int(that.some_ints): + error.path._prepend( + PropertySegment( + that, + 'some_ints' + ) + ) + yield error + _TRANSFORMER = _Transformer() @@ -284,5 +294,28 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + +def _verify_list_of__int( + that: Sequence[int] +) -> Iterator[Error]: + """Verify the items of :paramref:`that` recursively.""" + for i, item in enumerate(that): + for error in _verify_int(item): + error.path._prepend( + IndexSegment( + that, + i + ) + ) + yield error + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/xmlization.py index 9f09c5974..9374e6422 100644 --- a/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/list_of_primitives/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -937,7 +938,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -988,6 +990,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1566,6 +1577,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 8b9fe0b39..886a0a8a0 100644 --- a/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -62,23 +62,48 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_box_size_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.box_from_jsonable( - _load_the_first_expected('Box') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_box_size(self) -> None: + jsonable = _load_the_first_expected('Box') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['size'] = 5.0 + instance = our_jsonization.box_from_jsonable(jsonable) + self.assertIs(int, type(instance.size)) + self.assertEqual(5, instance.size) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.size = value + ]: + jsonable['size'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.box_from_jsonable(jsonable) self.assertEqual( - '.size', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..ccceb30e1 --- /dev/null +++ b/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_box_size_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.box_from_jsonable( + _load_the_first_expected('Box') + ) + + instance.size = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.size', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.size', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.size', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_box_size_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.box_from_jsonable( + _load_the_first_expected('Box') + ) + + instance.size = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_xmlization_of_concrete_classes.py b/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_xmlization_of_concrete_classes.py index d01f37ac5..5c4a3ee80 100644 --- a/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_xmlization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/method_references/expected_output/dev/tests/test_xmlization_of_concrete_classes.py @@ -118,6 +118,24 @@ def test_size_read_from_many_digits_fails(self) -> None: with self.assertRaises(our_xmlization.DeserializationException): self._read_with('size', "1" + "0" * 5000) + def test_size_read_at_the_safe_bounds(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = self._read_with('size', str(value)) + self.assertEqual(value, instance.size) + + def test_size_read_outside_the_safe_range_fails(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + with self.assertRaises( + our_xmlization.DeserializationException + ) as context_manager: + self._read_with('size', str(value)) + + self.assertEqual( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + context_manager.exception.cause + ) + class TestDuplicateProperty(unittest.TestCase): """Test that a property given more than once is refused.""" diff --git a/dev/test_data/main/python/expected/method_references/expected_output/dummy/common.py b/dev/test_data/main/python/expected/method_references/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/method_references/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/method_references/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/method_references/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/method_references/expected_output/dummy/jsonization.py index 323b3f845..e0f583fbf 100644 --- a/dev/test_data/main/python/expected/method_references/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/method_references/expected_output/dummy/jsonization.py @@ -332,6 +332,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -339,11 +346,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -529,20 +556,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/method_references/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/method_references/expected_output/dummy/verification.py index 673b33655..3785805cf 100644 --- a/dev/test_data/main/python/expected/method_references/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/method_references/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -88,6 +89,15 @@ def transform_box( 'The size must be positive.' ) + for error in _verify_int(that.size): + error.path._prepend( + PropertySegment( + that, + 'size' + ) + ) + yield error + _TRANSFORMER = _Transformer() @@ -104,5 +114,13 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/method_references/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/method_references/expected_output/dummy/xmlization.py index 5f9b9a662..827d1122f 100644 --- a/dev/test_data/main/python/expected/method_references/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/method_references/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -932,7 +933,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -983,6 +985,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1252,6 +1263,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/nested_collections/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/nested_collections/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 0ea534c98..3b323b884 100644 --- a/dev/test_data/main/python/expected/nested_collections/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/nested_collections/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -110,23 +110,48 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_another_item_serial_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.another_item_from_jsonable( - _load_the_first_expected('AnotherItem') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_another_item_serial_number(self) -> None: + jsonable = _load_the_first_expected('AnotherItem') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['serialNumber'] = 5.0 + instance = our_jsonization.another_item_from_jsonable(jsonable) + self.assertIs(int, type(instance.serial_number)) + self.assertEqual(5, instance.serial_number) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.serial_number = value + ]: + jsonable['serialNumber'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.another_item_from_jsonable(jsonable) self.assertEqual( - '.serial_number', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/nested_collections/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/nested_collections/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..dca865a07 --- /dev/null +++ b/dev/test_data/main/python/expected/nested_collections/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_another_item_serial_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.another_item_from_jsonable( + _load_the_first_expected('AnotherItem') + ) + + instance.serial_number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.serial_number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.serial_number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.serial_number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_another_item_serial_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.another_item_from_jsonable( + _load_the_first_expected('AnotherItem') + ) + + instance.serial_number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/common.py b/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/jsonization.py index 198963a7c..0c0792c7c 100644 --- a/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/jsonization.py @@ -333,6 +333,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -340,11 +347,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -1187,20 +1214,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/verification.py index 55d8404a8..5b4a31827 100644 --- a/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/verification.py @@ -48,6 +48,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -142,12 +143,14 @@ def transform_another_item( self, that: our_types.AnotherItem ) -> Iterator[Error]: - # No verification has been defined for AnotherItem. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.serial_number): + error.path._prepend( + PropertySegment( + that, + 'serial_number' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_something( @@ -220,6 +223,15 @@ def transform_something( 'Grid must be rectangular.' ) + for error in _verify_list_of__list_of__int(that.grid): + error.path._prepend( + PropertySegment( + that, + 'grid' + ) + ) + yield error + for error in _verify_list_of__list_of__tuple2_of__positive_int__set_of__str( that.rows_of_pairs ): @@ -279,6 +291,44 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + +def _verify_list_of__list_of__int( + that: Sequence[Sequence[int]] +) -> Iterator[Error]: + """Verify the items of :paramref:`that` recursively.""" + for i, item in enumerate(that): + for error in _verify_list_of__int(item): + error.path._prepend( + IndexSegment( + that, + i + ) + ) + yield error + + +def _verify_list_of__int( + that: Sequence[int] +) -> Iterator[Error]: + """Verify the items of :paramref:`that` recursively.""" + for i, item in enumerate(that): + for error in _verify_int(item): + error.path._prepend( + IndexSegment( + that, + i + ) + ) + yield error + + def _verify_list_of__list_of__tuple2_of__positive_int__set_of__str( that: Sequence[Sequence[Tuple[int, AbstractSet[str]]]] ) -> Iterator[Error]: @@ -371,6 +421,15 @@ def _verify_tuple2_of__int__code( that: Tuple[int, str] ) -> Iterator[Error]: """Verify the items of :paramref:`that` recursively.""" + for error in _verify_int(that[0]): + error.path._prepend( + IndexSegment( + that, + 0 + ) + ) + yield error + for error in verify_code(that[1]): error.path._prepend( IndexSegment( @@ -400,6 +459,8 @@ def verify_positive_int( that: int ) -> Iterator[Error]: """Verify the constraints of :paramref:`that`.""" + yield from _verify_int(that) + if not (that > 0): yield Error( 'Positive int must be positive.' diff --git a/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/xmlization.py index f0977e33f..2389200de 100644 --- a/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/nested_collections/expected_output/dummy/xmlization.py @@ -104,6 +104,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -1474,7 +1475,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1525,6 +1527,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -2664,6 +2675,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/optional_arguments/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/optional_arguments/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..f569f5b07 --- /dev/null +++ b/dev/test_data/main/python/expected/optional_arguments/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,35 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import unittest + + +import dummy.common as our_common + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/common.py b/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/jsonization.py index 24f0b6f03..a487aaa59 100644 --- a/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/jsonization.py @@ -290,6 +290,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -297,11 +304,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -573,20 +600,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/verification.py index 4104807e6..3f65297fe 100644 --- a/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -222,6 +223,16 @@ def transform_something( ) yield error + if that.optional_number is not None: + for error in _verify_int(that.optional_number): + error.path._prepend( + PropertySegment( + that, + 'optional_number' + ) + ) + yield error + if that.optional_item is not None: for error in verify(that.optional_item): error.path._prepend( @@ -248,5 +259,13 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/xmlization.py index cbd9124b9..52009c8f6 100644 --- a/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/optional_arguments/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -998,7 +999,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1049,6 +1051,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1460,6 +1471,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index ac25bb3d4..689af4693 100644 --- a/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -64,13 +64,13 @@ def _load_the_first_expected(model_type: str) -> Any: class TestSerializationFailures(unittest.TestCase): - def test_something_some_int_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: + def test_something_some_float_non_finite(self) -> None: + for value in [math.inf, -math.inf, math.nan]: instance = our_jsonization.something_from_jsonable( _load_the_first_expected('Something') ) - instance.some_int = value + instance.some_float = value with self.assertRaises( our_jsonization.SerializationException @@ -78,26 +78,53 @@ def test_something_some_int_out_of_range(self) -> None: our_jsonization.to_jsonable(instance) self.assertEqual( - '.some_int', + '.some_float', context_manager.exception.path ) - def test_something_some_float_non_finite(self) -> None: - for value in [math.inf, -math.inf, math.nan]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) - instance.some_float = value +class TestDeserializationOfIntegers(unittest.TestCase): + def test_something_some_int(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['someInt'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.some_int)) + self.assertEqual(5, instance.some_int) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['someInt'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.some_float', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..98b5cc96d --- /dev/null +++ b/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_something_some_int_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.some_int = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.some_int', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.some_int', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.some_int', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_some_int_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.some_int = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_xmlization_of_concrete_classes.py b/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_xmlization_of_concrete_classes.py index fdf2adac6..5dc0ccd3b 100644 --- a/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_xmlization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/primitive_types/expected_output/dev/tests/test_xmlization_of_concrete_classes.py @@ -175,6 +175,24 @@ def test_some_int_read_from_many_digits_fails(self) -> None: with self.assertRaises(our_xmlization.DeserializationException): self._read_with('someInt', "1" + "0" * 5000) + def test_some_int_read_at_the_safe_bounds(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = self._read_with('someInt', str(value)) + self.assertEqual(value, instance.some_int) + + def test_some_int_read_outside_the_safe_range_fails(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + with self.assertRaises( + our_xmlization.DeserializationException + ) as context_manager: + self._read_with('someInt', str(value)) + + self.assertEqual( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + context_manager.exception.cause + ) + class TestDuplicateProperty(unittest.TestCase): """Test that a property given more than once is refused.""" diff --git a/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/common.py b/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/jsonization.py index 1f3d3ec0e..421a8e26f 100644 --- a/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/jsonization.py @@ -309,6 +309,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -316,11 +323,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" + ) + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) ) - return jsonable + + return value def _float_from_jsonable( @@ -546,20 +573,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/verification.py index b65816a9b..220d2593d 100644 --- a/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -72,12 +73,14 @@ def transform_something( self, that: our_types.Something ) -> Iterator[Error]: - # No verification has been defined for Something. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.some_int): + error.path._prepend( + PropertySegment( + that, + 'some_int' + ) + ) + yield error _TRANSFORMER = _Transformer() @@ -95,5 +98,13 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/xmlization.py index 235d4012d..4172e00b6 100644 --- a/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/primitive_types/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -871,7 +872,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -922,6 +924,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1336,6 +1347,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/sets/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/sets/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index fa0722f25..cc4d288ff 100644 --- a/dev/test_data/main/python/expected/sets/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/sets/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -86,41 +86,48 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_something_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) - - instance.number = value - - with self.assertRaises( - our_jsonization.SerializationException - ) as context_manager: - our_jsonization.to_jsonable(instance) - - self.assertEqual( - '.number', - context_manager.exception.path - ) - - def test_something_numbers_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_something_number(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['number'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.number)) + self.assertEqual(5, instance.number) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.numbers = [0, value] + ]: + jsonable['number'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.numbers[1]', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/sets/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/sets/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..741d95c43 --- /dev/null +++ b/dev/test_data/main/python/expected/sets/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,193 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_something_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_numbers_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.numbers = [0, value] + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.numbers[1]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.numbers[1]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.numbers[1]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_numbers_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.numbers = [0, value] + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/sets/expected_output/dummy/common.py b/dev/test_data/main/python/expected/sets/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/sets/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/sets/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/sets/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/sets/expected_output/dummy/jsonization.py index c13bd4b13..deb421121 100644 --- a/dev/test_data/main/python/expected/sets/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/sets/expected_output/dummy/jsonization.py @@ -308,6 +308,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -315,11 +322,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -869,20 +896,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/sets/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/sets/expected_output/dummy/verification.py index c97fbf610..619b36ba3 100644 --- a/dev/test_data/main/python/expected/sets/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/sets/expected_output/dummy/verification.py @@ -48,6 +48,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -419,6 +420,24 @@ def transform_something( 'Texts must be unique.' ) + for error in _verify_int(that.number): + error.path._prepend( + PropertySegment( + that, + 'number' + ) + ) + yield error + + for error in _verify_list_of__int(that.numbers): + error.path._prepend( + PropertySegment( + that, + 'numbers' + ) + ) + yield error + for error in _verify_list_of__code(that.codes): error.path._prepend( PropertySegment( @@ -476,6 +495,15 @@ def transform_collection( 'Texts must not contain the forbidden text.' ) + for error in _verify_set_of__int(that.numbers): + error.path._prepend( + PropertySegment( + that, + 'numbers' + ) + ) + yield error + for error in _verify_set_of__code(that.codes): error.path._prepend( PropertySegment( @@ -501,6 +529,29 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + +def _verify_list_of__int( + that: Sequence[int] +) -> Iterator[Error]: + """Verify the items of :paramref:`that` recursively.""" + for i, item in enumerate(that): + for error in _verify_int(item): + error.path._prepend( + IndexSegment( + that, + i + ) + ) + yield error + + def _verify_list_of__code( that: Sequence[str] ) -> Iterator[Error]: @@ -516,6 +567,22 @@ def _verify_list_of__code( yield error +def _verify_set_of__int( + that: AbstractSet[int] +) -> Iterator[Error]: + """Verify the items of :paramref:`that` recursively.""" + sorted_that = sorted(that) + for i, item in enumerate(sorted_that): + for error in _verify_int(item): + error.path._prepend( + IndexSegment( + sorted_that, + i + ) + ) + yield error + + def _verify_set_of__code( that: AbstractSet[str] ) -> Iterator[Error]: diff --git a/dev/test_data/main/python/expected/sets/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/sets/expected_output/dummy/xmlization.py index c55c4a9f5..44b94f480 100644 --- a/dev/test_data/main/python/expected/sets/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/sets/expected_output/dummy/xmlization.py @@ -104,6 +104,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -1115,7 +1116,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1166,6 +1168,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1881,6 +1892,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/switch_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/switch_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 5fb52b79c..ec4554589 100644 --- a/dev/test_data/main/python/expected/switch_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/switch_statements/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -62,23 +62,48 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_something_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_something_number(self) -> None: + jsonable = _load_the_first_expected('Something') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['number'] = 5.0 + instance = our_jsonization.something_from_jsonable(jsonable) + self.assertIs(int, type(instance.number)) + self.assertEqual(5, instance.number) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.number = value + ]: + jsonable['number'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.something_from_jsonable(jsonable) self.assertEqual( - '.number', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/switch_statements/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/switch_statements/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..766ca9c7b --- /dev/null +++ b/dev/test_data/main/python/expected/switch_statements/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_something_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/common.py b/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/jsonization.py index 68af1383b..27234fb78 100644 --- a/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/jsonization.py @@ -290,6 +290,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -297,11 +304,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" + ) + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) ) - return jsonable + + return value def _str_from_jsonable( @@ -482,20 +509,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/verification.py index 768921244..227d4adb2 100644 --- a/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -296,6 +297,15 @@ def transform_something( ) yield error + for error in _verify_int(that.number): + error.path._prepend( + PropertySegment( + that, + 'number' + ) + ) + yield error + _TRANSFORMER = _Transformer() @@ -312,6 +322,14 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + def verify_non_empty_string( that: str ) -> Iterator[Error]: diff --git a/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/xmlization.py index 6b68176c3..0bf9565b4 100644 --- a/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/switch_statements/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -753,7 +754,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -804,6 +806,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1109,6 +1120,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 42a07fb12..dac833f87 100644 --- a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -86,41 +86,91 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_first_count_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.first_from_jsonable( - _load_the_first_expected('First') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_first_count(self) -> None: + jsonable = _load_the_first_expected('First') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['count'] = 5.0 + instance = our_jsonization.first_from_jsonable(jsonable) + self.assertIs(int, type(instance.count)) + self.assertEqual(5, instance.count) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.count = value + ]: + jsonable['count'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.first_from_jsonable(jsonable) self.assertEqual( - '.count', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) - def test_second_count_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.second_from_jsonable( - _load_the_first_expected('Second') - ) + def test_second_count(self) -> None: + jsonable = _load_the_first_expected('Second') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['count'] = 5.0 + instance = our_jsonization.second_from_jsonable(jsonable) + self.assertIs(int, type(instance.count)) + self.assertEqual(5, instance.count) - instance.count = value + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ) + ]: + jsonable['count'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.second_from_jsonable(jsonable) self.assertEqual( - '.count', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..4648801c0 --- /dev/null +++ b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,193 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_first_count_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.first_from_jsonable( + _load_the_first_expected('First') + ) + + instance.count = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.count', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.count', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.count', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_first_count_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.first_from_jsonable( + _load_the_first_expected('First') + ) + + instance.count = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_second_count_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.second_from_jsonable( + _load_the_first_expected('Second') + ) + + instance.count = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.count', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.count', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.count', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_second_count_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.second_from_jsonable( + _load_the_first_expected('Second') + ) + + instance.count = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/common.py b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/jsonization.py index 5dafde536..7aea34a24 100644 --- a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/jsonization.py @@ -332,6 +332,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -339,11 +346,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -697,20 +724,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/verification.py index 2f3fe2c6f..29d269b04 100644 --- a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -110,6 +111,15 @@ def transform_first( 'Parity is either zero or one.' ) + for error in _verify_int(that.count): + error.path._prepend( + PropertySegment( + that, + 'count' + ) + ) + yield error + # noinspection PyMethodMayBeStatic def transform_second( self, @@ -140,6 +150,15 @@ def transform_second( 'Prefix is at most three long.' ) + for error in _verify_int(that.count): + error.path._prepend( + PropertySegment( + that, + 'count' + ) + ) + yield error + _TRANSFORMER = _Transformer() @@ -156,5 +175,13 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/xmlization.py index 83983ea8d..3108bb780 100644 --- a/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/transpiled_methods/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -1177,7 +1178,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1228,6 +1230,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -1692,6 +1703,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/tuples/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/tuples/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index ade1417d0..3b323b884 100644 --- a/dev/test_data/main/python/expected/tuples/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/tuples/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -110,59 +110,48 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_another_item_serial_number_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.another_item_from_jsonable( - _load_the_first_expected('AnotherItem') - ) - - instance.serial_number = value - - with self.assertRaises( - our_jsonization.SerializationException - ) as context_manager: - our_jsonization.to_jsonable(instance) - - self.assertEqual( - '.serial_number', - context_manager.exception.path - ) - - def test_something_pair_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') - ) - - instance.pair = (instance.pair[0], value) - - with self.assertRaises( - our_jsonization.SerializationException - ) as context_manager: - our_jsonization.to_jsonable(instance) - - self.assertEqual( - '.pair[1]', - context_manager.exception.path - ) - - def test_something_tricky_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.something_from_jsonable( - _load_the_first_expected('Something') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_another_item_serial_number(self) -> None: + jsonable = _load_the_first_expected('AnotherItem') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['serialNumber'] = 5.0 + instance = our_jsonization.another_item_from_jsonable(jsonable) + self.assertIs(int, type(instance.serial_number)) + self.assertEqual(5, instance.serial_number) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.tricky = (value, instance.tricky[1], instance.tricky[2], instance.tricky[3], instance.tricky[4], instance.tricky[5]) + ]: + jsonable['serialNumber'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.another_item_from_jsonable(jsonable) self.assertEqual( - '.tricky[0]', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/tuples/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/tuples/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..8245e2d2c --- /dev/null +++ b/dev/test_data/main/python/expected/tuples/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,254 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_another_item_serial_number_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.another_item_from_jsonable( + _load_the_first_expected('AnotherItem') + ) + + instance.serial_number = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.serial_number', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.serial_number', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.serial_number', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_another_item_serial_number_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.another_item_from_jsonable( + _load_the_first_expected('AnotherItem') + ) + + instance.serial_number = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_pair_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.pair = (instance.pair[0], value) + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.pair[1]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.pair[1]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.pair[1]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_pair_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.pair = (instance.pair[0], value) + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + def test_something_tricky_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.tricky = (value, instance.tricky[1], instance.tricky[2], instance.tricky[3], instance.tricky[4], instance.tricky[5]) + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.tricky[0]', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.tricky[0]', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.tricky[0]', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_something_tricky_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.something_from_jsonable( + _load_the_first_expected('Something') + ) + + instance.tricky = (value, instance.tricky[1], instance.tricky[2], instance.tricky[3], instance.tricky[4], instance.tricky[5]) + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/tuples/expected_output/dummy/common.py b/dev/test_data/main/python/expected/tuples/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/tuples/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/tuples/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/tuples/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/tuples/expected_output/dummy/jsonization.py index 2f0711200..6268a07fa 100644 --- a/dev/test_data/main/python/expected/tuples/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/tuples/expected_output/dummy/jsonization.py @@ -332,6 +332,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -339,11 +346,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -928,20 +955,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/tuples/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/tuples/expected_output/dummy/verification.py index 30d2d118b..d7b69e4f8 100644 --- a/dev/test_data/main/python/expected/tuples/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/tuples/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -106,12 +107,14 @@ def transform_another_item( self, that: our_types.AnotherItem ) -> Iterator[Error]: - # No verification has been defined for AnotherItem. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.serial_number): + error.path._prepend( + PropertySegment( + that, + 'serial_number' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_something( @@ -136,6 +139,15 @@ def transform_something( 'The optional pair must have two items' ) + for error in _verify_tuple2_of__str__int(that.pair): + error.path._prepend( + PropertySegment( + that, + 'pair' + ) + ) + yield error + for error in _verify_tuple2_of__class__class(that.items): error.path._prepend( PropertySegment( @@ -182,6 +194,28 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + +def _verify_tuple2_of__str__int( + that: Tuple[str, int] +) -> Iterator[Error]: + """Verify the items of :paramref:`that` recursively.""" + for error in _verify_int(that[1]): + error.path._prepend( + IndexSegment( + that, + 1 + ) + ) + yield error + + def _verify_tuple2_of__class__class( that: Tuple[our_types.Class, our_types.Class] ) -> Iterator[Error]: @@ -216,6 +250,15 @@ def _verify_tuple6_of__int__class__class__class__positive_int__result( ] ) -> Iterator[Error]: """Verify the items of :paramref:`that` recursively.""" + for error in _verify_int(that[0]): + error.path._prepend( + IndexSegment( + that, + 0 + ) + ) + yield error + for error in verify(that[1]): error.path._prepend( IndexSegment( @@ -271,6 +314,8 @@ def verify_positive_int( that: int ) -> Iterator[Error]: """Verify the constraints of :paramref:`that`.""" + yield from _verify_int(that) + if not (that > 0): yield Error( 'Larger than zero' diff --git a/dev/test_data/main/python/expected/tuples/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/tuples/expected_output/dummy/xmlization.py index 98c689f20..a53f3c1b7 100644 --- a/dev/test_data/main/python/expected/tuples/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/tuples/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -1331,7 +1332,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1382,6 +1384,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -2189,6 +2200,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dev/tests/test_jsonization_of_concrete_classes.py b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dev/tests/test_jsonization_of_concrete_classes.py index 1ca69c9e8..79967ec31 100644 --- a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dev/tests/test_jsonization_of_concrete_classes.py +++ b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dev/tests/test_jsonization_of_concrete_classes.py @@ -134,23 +134,48 @@ def _load_the_first_expected(model_type: str) -> Any: return json.load(fid) -class TestSerializationFailures(unittest.TestCase): - def test_special_item_level_out_of_range(self) -> None: - for value in [9007199254740992, -9007199254740992]: - instance = our_jsonization.special_item_from_jsonable( - _load_the_first_expected('SpecialItem') +class TestDeserializationOfIntegers(unittest.TestCase): + def test_special_item_level(self) -> None: + jsonable = _load_the_first_expected('SpecialItem') + + # NOTE (mristin): + # JSON has a single number type, so a whole float is an integer. + jsonable['level'] = 5.0 + instance = our_jsonization.special_item_from_jsonable(jsonable) + self.assertIs(int, type(instance.level)) + self.assertEqual(5, instance.level) + + for value, expected_cause in [ + ("5", "Expected an integer, but got: "), + ( + 1.5, + "Expected an integer, but got 1.5, which is not a whole number" + ), + ( + 9007199254740992, + ( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + ), + ( + -9007199254740992.0, + ( + "Expected an integer, but got -9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) ) - - instance.level = value + ]: + jsonable['level'] = value with self.assertRaises( - our_jsonization.SerializationException + our_jsonization.DeserializationException ) as context_manager: - our_jsonization.to_jsonable(instance) + our_jsonization.special_item_from_jsonable(jsonable) self.assertEqual( - '.level', - context_manager.exception.path + expected_cause, + context_manager.exception.cause ) diff --git a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dev/tests/test_safe_integers.py b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dev/tests/test_safe_integers.py new file mode 100644 index 000000000..d77842d44 --- /dev/null +++ b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dev/tests/test_safe_integers.py @@ -0,0 +1,132 @@ +"""Test that the integers are checked against the safe integer range.""" + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. + + +import json +from typing import Any, Iterable, List, Tuple +import unittest + + +import dummy.common as our_common +import dummy.jsonization as our_jsonization +import dummy.reporting as our_reporting +import dummy.verification as our_verification +import dummy.xmlization as our_xmlization + + +import tests.common + + +def _load_the_first_expected(model_type: str) -> Any: + """Load the first recorded example of the ``model_type``.""" + paths = sorted( + ( + tests.common.TEST_DATA_DIR + / "Json" + / "Expected" + / model_type + ).glob("**/*.json") + ) + + assert len(paths) > 0, ( + f"Expected at least one recorded example of {model_type}, but got none" + ) + + with paths[0].open("rt") as fid: + return json.load(fid) + + +def _paths_and_causes( + errors: Iterable[our_reporting.Error] +) -> List[Tuple[str, str]]: + return [(str(error.path), error.cause) for error in errors] + + +class TestPredicateAndMessages(unittest.TestCase): + def test_is_safe_integer(self) -> None: + for value in [0, 2 ** 53 - 1, -(2 ** 53 - 1)]: + self.assertTrue(our_common.is_safe_integer(value), f"for {value}") + + for value in [2 ** 53, -(2 ** 53), 10 ** 400]: + self.assertFalse(our_common.is_safe_integer(value), f"for {value}") + + def test_unsafe_integer_message(self) -> None: + self.assertEqual( + "Expected an integer, but got 9007199254740992, " + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]", + our_common.unsafe_integer_message(2 ** 53) + ) + + +class TestIntegerProperties(unittest.TestCase): + def test_special_item_level_unsafe(self) -> None: + for value in [2 ** 53, -(2 ** 53)]: + instance = our_jsonization.special_item_from_jsonable( + _load_the_first_expected('SpecialItem') + ) + + instance.level = value + + expected_cause = ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + self.assertIn( + ('.level', expected_cause), + _paths_and_causes(our_verification.verify(instance)) + ) + + with self.assertRaises( + our_jsonization.SerializationException + ) as json_context: + our_jsonization.to_jsonable(instance) + + self.assertEqual( + ('.level', expected_cause), + (json_context.exception.path, json_context.exception.cause) + ) + + with self.assertRaises( + our_xmlization.SerializationException + ) as xml_context: + our_xmlization.to_str(instance) + + self.assertEqual( + ('.level', expected_cause), + (xml_context.exception.path, xml_context.exception.cause) + ) + + def test_special_item_level_safe(self) -> None: + for value in [2 ** 53 - 1, -(2 ** 53 - 1)]: + instance = our_jsonization.special_item_from_jsonable( + _load_the_first_expected('SpecialItem') + ) + + instance.level = value + + # NOTE (mristin): + # The value might still violate an invariant, so we only check that + # the range is not reported. + self.assertListEqual( + [], + [ + (path, cause) + for path, cause in _paths_and_causes(our_verification.verify(instance)) + if "safe integer range" in cause + ] + ) + + our_jsonization.to_jsonable(instance) + our_xmlization.to_str(instance) + + +if __name__ == "__main__": + unittest.main() + + +# This code has been automatically generated by aas-core-codegen. +# Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/common.py b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/common.py index 43d22decf..6fd7454ef 100644 --- a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/common.py +++ b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/common.py @@ -85,5 +85,52 @@ def try_to_cast_to_array_like( return None +def is_safe_integer( + value: int +) -> bool: + """ + Check that :paramref:`value` lies in the safe integer range. + + The safe integer range [-2^53 + 1, 2^53 - 1] covers the integers which + a double-precision floating-point number represents exactly. JSON knows + a single numeric type, and the JSON de-serializers of the other languages + read a number into such a float, so an integer outside of the range might + quietly come out as a different number. + + Mind that a ``bool`` is an ``int`` in Python. The callers all sort out + the booleans before they come here. + + :param value: integer to be checked + :return: True if :paramref:`value` is a safe integer + + >>> is_safe_integer(2 ** 53 - 1) + True + + >>> is_safe_integer(-(2 ** 53 - 1)) + True + + >>> is_safe_integer(2 ** 53) + False + + >>> is_safe_integer(-(2 ** 53)) + False + """ + return -9007199254740991 <= value <= 9007199254740991 + + +def unsafe_integer_message( + value: int +) -> str: + """ + Describe that :paramref:`value` lies outside the safe integer range. + + See :py:func:`is_safe_integer`. + """ + return ( + f"Expected an integer, but got {value}, " + f"which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ) + + # This code has been automatically generated by aas-core-codegen. # Do NOT edit or append. diff --git a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/jsonization.py b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/jsonization.py index f86121271..ab21d4337 100644 --- a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/jsonization.py +++ b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/jsonization.py @@ -332,6 +332,13 @@ def _int_from_jsonable( """ Parse :paramref:`jsonable` as an integer. + A whole float, such as ``5.0`` or ``1e2``, is accepted as well. JSON has + a single number type, so ``5.0`` is every bit as good an integer as ``5`` + is, and :py:mod:`json` gives us a ``float`` for the former. + + Only the integers in the safe integer range [-2^53 + 1, 2^53 - 1] are + accepted, see :py:func:`.common.is_safe_integer`. + :param jsonable: JSON-able structure to be parsed :return: parsed integer :raise: :py:class:`DeserializationException` if unexpected :paramref:`jsonable` @@ -339,11 +346,31 @@ def _int_from_jsonable( # NOTE (mristin): # ``bool`` is a subclass of ``int`` in Python, so it has to be excluded # explicitly. Otherwise ``True`` would be read as ``1``. - if isinstance(jsonable, bool) or not isinstance(jsonable, int): + if isinstance(jsonable, bool) or not isinstance(jsonable, (int, float)): raise DeserializationException( - f"Expected an int, but got: {type(jsonable)}" + f"Expected an integer, but got: {type(jsonable)}" ) - return jsonable + + value: int + if isinstance(jsonable, float): + # NOTE (mristin): + # ``is_integer`` is false for an infinity and a not-a-number as well. + if not jsonable.is_integer(): + raise DeserializationException( + f"Expected an integer, but got {jsonable}, " + f"which is not a whole number" + ) + + value = int(jsonable) + else: + value = jsonable + + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + + return value def _str_from_jsonable( @@ -808,20 +835,18 @@ def _int_to_jsonable( """ Serialize :paramref:`that` integer to a JSON-able value. - Only the integers in the range :math:`[-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, see :py:func:`.common.is_safe_integer`. :param that: integer to be serialized :return: :paramref:`that`, unchanged :raise: :py:class:`SerializationException` if outside the range """ - if that < -9007199254740991 or that > 9007199254740991: + if not our_common.is_safe_integer(that): raise SerializationException( - f"The integer can not be serialized to JSON as it is outside " - f"the range [-2^53 + 1, 2^53 - 1]: {that}" + our_common.unsafe_integer_message(that) ) + return that diff --git a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/verification.py b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/verification.py index 2b36d2bc5..3dcb086d8 100644 --- a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/verification.py +++ b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/verification.py @@ -47,6 +47,7 @@ from typing_extensions import Final from dummy import ( + common as our_common, constants as our_constants, reporting as our_reporting, types as our_types, @@ -378,12 +379,14 @@ def transform_special_item( self, that: our_types.SpecialItem ) -> Iterator[Error]: - # No verification has been defined for SpecialItem. - return - # For this uncommon return-yield construction, see: - # https://stackoverflow.com/questions/13243766/how-to-define-an-empty-generator-function - # noinspection PyUnreachableCode - yield + for error in _verify_int(that.level): + error.path._prepend( + PropertySegment( + that, + 'level' + ) + ) + yield error # noinspection PyMethodMayBeStatic def transform_container( @@ -582,6 +585,14 @@ def verify( yield from _TRANSFORMER.transform(that) +def _verify_int( + that: int +) -> Iterator[Error]: + """Verify that :paramref:`that` lies in the safe integer range.""" + if not our_common.is_safe_integer(that): + yield Error(our_common.unsafe_integer_message(that)) + + def _verify_list_of__class( that: Sequence[our_types.Class] ) -> Iterator[Error]: diff --git a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/xmlization.py b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/xmlization.py index 65d5a7539..cc3fb3ca0 100644 --- a/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/xmlization.py +++ b/dev/test_data/main/python/expected/variance_of_containers/expected_output/dummy/xmlization.py @@ -103,6 +103,7 @@ else: from typing_extensions import Final +import dummy.common as our_common import dummy.stringification as our_stringification import dummy.types as our_types import dummy.xmlcommon as our_xmlcommon @@ -1535,7 +1536,8 @@ def _read_int_from_element_text( ``events=["start", "end"]`` :raise: :py:class:`DeserializationException` if unexpected input, including - a value outside of the 64-bit range of ``xs:long`` + a value outside of the 64-bit range of ``xs:long`` and outside of + the safe integer range, see :py:func:`.common.is_safe_integer` :return: parsed value """ text = collapse_whitespace( @@ -1586,6 +1588,15 @@ def _read_int_from_element_text( f"but got an element with text out of its range: {text!r}" ) + # NOTE (mristin): + # A valid ``xs:long`` is not necessarily serializable to JSON, so we refuse + # the values outside of the safe integer range, as the JSON de-serialization + # and the verification do. + if not our_common.is_safe_integer(value): + raise DeserializationException( + our_common.unsafe_integer_message(value) + ) + return value @@ -2151,6 +2162,14 @@ def _write_int_as_element( :raise: :py:class:`SerializationException` if the value could not be written """ try: + # NOTE (mristin): + # We refuse the values outside of the safe integer range, as the JSON + # serialization and the verification do. + if not our_common.is_safe_integer(value): + raise SerializationException( + our_common.unsafe_integer_message(value) + ) + serializer.writer.write_start_element(name) serializer.writer.stream.write(str(value)) serializer.writer.write_end_element(name) diff --git a/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/src/jsonization.ts index 92cf5d76b..9c24c617d 100644 --- a/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/src/xmlization.ts index 520bfe318..04f3cf0cf 100644 --- a/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/src/xmlization.ts @@ -192,38 +192,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -11752,25 +11720,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/test/xmlization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/test/xmlization.concreteClasses.spec.ts index 19c9c4be0..500f75a31 100644 --- a/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/test/xmlization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/aas_core_meta.v3/expected_output/test/xmlization.concreteClasses.spec.ts @@ -2754,7 +2754,10 @@ test("DataSpecificationIec61360 XML deserialization fail", () => { * Read the first recorded example of LevelType with the content * of the element `xmlName` replaced by `text`. */ -function readWith(xmlName: string, text: string): OurTypes.LevelType { +function readEitherWith( + xmlName: string, + text: string +): ReturnType { const pths = Array.from( TestCommon.findFilesBySuffixRecursively( path.join( @@ -2777,7 +2780,15 @@ function readWith(xmlName: string, text: string): OurTypes.LevelType { const patched = original.slice(0, start) + text + original.slice(end); - const instanceOrError = OurXmlization.fromXmlString(patched); + return OurXmlization.fromXmlString(patched); +} + +/** + * Read the first recorded example of LevelType with the content + * of the element `xmlName` replaced by `text`, and expect no error. + */ +function readWith(xmlName: string, text: string): OurTypes.LevelType { + const instanceOrError = readEitherWith(xmlName, text); expect(instanceOrError.error).toBeNull(); const casted = OurTypes.asLevelType(instanceOrError.mustValue()); diff --git a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/common.ts index 561a6c2dd..5405db1c9 100644 --- a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/common.ts @@ -503,6 +503,37 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + /** * Give a new set of the items which are both in `that` and in `other`. */ diff --git a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/jsonization.ts index 833071287..998e10c2b 100644 --- a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -1485,21 +1498,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/verification.ts index 67480e8ce..ac6051e5b 100644 --- a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/verification.ts @@ -576,6 +576,22 @@ export function noneIsShortAndTextIsShort( ); } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify the items of `that` recursively. */ @@ -612,21 +628,35 @@ class Verifier } *transformChildAWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.ChildA, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for ChildA. + for (const error of verifyInteger(that.aOnly)) { + error.path.prepend( + new PropertySegment( + that, + "aOnly" + ) + ); + yield error; + } } *transformChildBWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.ChildB, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for ChildB. + for (const error of verifyInteger(that.bOnly)) { + error.path.prepend( + new PropertySegment( + that, + "bOnly" + ) + ); + yield error; + } } *transformSomethingWithContext( @@ -783,6 +813,16 @@ class Verifier ) } + for (const error of verifyInteger(that.number)) { + error.path.prepend( + new PropertySegment( + that, + "number" + ) + ); + yield error; + } + for (const error of verifyCode(that.code)) { error.path.prepend( new PropertySegment( diff --git a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/xmlization.ts index e8125301d..a2f9a0e16 100644 --- a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/src/xmlization.ts @@ -207,17 +207,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -1500,15 +1496,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/test/jsonization.concreteClasses.spec.ts index e2cff342d..caffdd410 100644 --- a/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/annotated_assignments/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -731,9 +732,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Child_a serialization fails on an integer outside the range representable in JSON " - + "at .aOnly", + "Child_a refuses an integer outside the safe integer range " + + "at .aOnly in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.childAFromJsonable( @@ -742,27 +772,52 @@ test( instance.aOnly = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.aOnly: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Child_a accepts the bounds of the safe integer range " + + "at .aOnly in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.childAFromJsonable( + loadTheFirstExpected("ChildA") + ).mustValue(); - expect(caught.path).toStrictEqual(".aOnly"); + instance.aOnly = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Child_b serialization fails on an integer outside the range representable in JSON " - + "at .bOnly", + "Child_b refuses an integer outside the safe integer range " + + "at .bOnly in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.childBFromJsonable( @@ -771,27 +826,52 @@ test( instance.bOnly = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.bOnly: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Child_b accepts the bounds of the safe integer range " + + "at .bOnly in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.childBFromJsonable( + loadTheFirstExpected("ChildB") + ).mustValue(); - expect(caught.path).toStrictEqual(".bOnly"); + instance.bOnly = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .number", + "Something refuses an integer outside the safe integer range " + + "at .number in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -800,20 +880,45 @@ test( instance.number = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.number: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .number in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".number"); + instance.number = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/common.ts index 2e04d4ae3..182707a14 100644 --- a/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/common.ts @@ -544,5 +544,36 @@ export function floorMod(dividend: number, divisor: number): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/jsonization.ts index e2afc3079..ba73b2e8a 100644 --- a/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -867,21 +880,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/verification.ts index b87fb52ab..146f750ba 100644 --- a/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/verification.ts @@ -257,6 +257,22 @@ export function hasRemainderOneByLength( (OurCommon.floorMod(number, OurCommon.lenStr(text) + 1)) == (1)); } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -362,10 +378,7 @@ class Verifier ) } - if (!Number.isInteger(that.even)) { - const error = new VerificationError( - "Expected an integer, but got a floating-point number" - ); + for (const error of verifyEvenInt(that.even)) { error.path.prepend( new PropertySegment( that, @@ -374,15 +387,78 @@ class Verifier ); yield error; } - for (const error of verifyEvenInt(that.even)) { + + for (const error of verifyInteger(that.offset)) { error.path.prepend( new PropertySegment( that, - "even" + "offset" ) ); yield error; } + + for (const error of verifyInteger(that.small)) { + error.path.prepend( + new PropertySegment( + that, + "small" + ) + ); + yield error; + } + + for (const error of verifyInteger(that.byNegative)) { + error.path.prepend( + new PropertySegment( + that, + "byNegative" + ) + ); + yield error; + } + + for (const error of verifyInteger(that.negative)) { + error.path.prepend( + new PropertySegment( + that, + "negative" + ) + ); + yield error; + } + + for (const error of verifyInteger(that.deviation)) { + error.path.prepend( + new PropertySegment( + that, + "deviation" + ) + ); + yield error; + } + + for (const error of verifyInteger(that.alignment)) { + error.path.prepend( + new PropertySegment( + that, + "alignment" + ) + ); + yield error; + } + + if (that.optionalNumber !== null) { + for (const error of verifyInteger(that.optionalNumber)) { + error.path.prepend( + new PropertySegment( + that, + "optionalNumber" + ) + ); + yield error; + } + } } } @@ -411,6 +487,11 @@ export function *verify( export function *verifyEvenInt( that: number ): IterableIterator { + if (!Number.isSafeInteger(that)) { + yield* verifyInteger(that); + return; + } + if (!((OurCommon.floorMod(that, 2)) == (0))) { yield new VerificationError( "Even" diff --git a/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/xmlization.ts index 34209e578..74292c7e2 100644 --- a/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/arithmetic/expected_output/src/xmlization.ts @@ -99,17 +99,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -739,15 +735,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/arithmetic/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/arithmetic/expected_output/test/jsonization.concreteClasses.spec.ts index 8d857353e..ac45170c9 100644 --- a/dev/test_data/main/typescript/expected/arithmetic/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/arithmetic/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -302,9 +303,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .even", + "Something refuses an integer outside the safe integer range " + + "at .even in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -313,27 +343,52 @@ test( instance.even = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.even: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .even in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.even = value; - expect(caught.path).toStrictEqual(".even"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .offset", + "Something refuses an integer outside the safe integer range " + + "at .offset in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -342,27 +397,52 @@ test( instance.offset = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.offset: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .offset in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".offset"); + instance.offset = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .small", + "Something refuses an integer outside the safe integer range " + + "at .small in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -371,27 +451,52 @@ test( instance.small = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.small: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .small in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.small = value; - expect(caught.path).toStrictEqual(".small"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .byNegative", + "Something refuses an integer outside the safe integer range " + + "at .byNegative in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -400,27 +505,52 @@ test( instance.byNegative = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.byNegative: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .byNegative in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.byNegative = value; - expect(caught.path).toStrictEqual(".byNegative"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .negative", + "Something refuses an integer outside the safe integer range " + + "at .negative in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -429,27 +559,52 @@ test( instance.negative = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.negative: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .negative in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".negative"); + instance.negative = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .deviation", + "Something refuses an integer outside the safe integer range " + + "at .deviation in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -458,20 +613,45 @@ test( instance.deviation = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.deviation: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .deviation in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.deviation = value; - expect(caught.path).toStrictEqual(".deviation"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); @@ -506,8 +686,8 @@ test( ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .alignment", + "Something refuses an integer outside the safe integer range " + + "at .alignment in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -516,20 +696,45 @@ test( instance.alignment = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.alignment: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .alignment in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".alignment"); + instance.alignment = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/common.ts index 11a40830a..208c38cc4 100644 --- a/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/common.ts @@ -503,5 +503,36 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/jsonization.ts index d05bc9243..ade71b07c 100644 --- a/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -1073,21 +1086,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/verification.ts index 602503592..738bbcfe3 100644 --- a/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/verification.ts @@ -286,6 +286,22 @@ export function textsAreConsistent( return true; } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -294,21 +310,45 @@ class Verifier boolean, IterableIterator > { *transformChildAWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.ChildA, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for ChildA. + for (const error of verifyInteger(that.number)) { + error.path.prepend( + new PropertySegment( + that, + "number" + ) + ); + yield error; + } + + for (const error of verifyInteger(that.aOnly)) { + error.path.prepend( + new PropertySegment( + that, + "aOnly" + ) + ); + yield error; + } } *transformChildBWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.ChildB, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for ChildB. + for (const error of verifyInteger(that.number)) { + error.path.prepend( + new PropertySegment( + that, + "number" + ) + ); + yield error; + } } *transformSomethingWithContext( @@ -363,6 +403,16 @@ class Verifier ) } + for (const error of verifyInteger(that.number)) { + error.path.prepend( + new PropertySegment( + that, + "number" + ) + ); + yield error; + } + if (context === true) { for (const error of verify(that.parent)) { error.path.prepend( diff --git a/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/xmlization.ts index 502004925..63ba81d4c 100644 --- a/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/assert_statements/expected_output/src/xmlization.ts @@ -176,17 +176,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -990,15 +986,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/assert_statements/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/assert_statements/expected_output/test/jsonization.concreteClasses.spec.ts index 872acff54..c0df51679 100644 --- a/dev/test_data/main/typescript/expected/assert_statements/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/assert_statements/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -588,9 +589,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Child_a serialization fails on an integer outside the range representable in JSON " - + "at .number", + "Child_a refuses an integer outside the safe integer range " + + "at .number in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.childAFromJsonable( @@ -599,27 +629,52 @@ test( instance.number = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.number: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Child_a accepts the bounds of the safe integer range " + + "at .number in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.childAFromJsonable( + loadTheFirstExpected("ChildA") + ).mustValue(); + + instance.number = value; - expect(caught.path).toStrictEqual(".number"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Child_a serialization fails on an integer outside the range representable in JSON " - + "at .aOnly", + "Child_a refuses an integer outside the safe integer range " + + "at .aOnly in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.childAFromJsonable( @@ -628,27 +683,52 @@ test( instance.aOnly = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.aOnly: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Child_a accepts the bounds of the safe integer range " + + "at .aOnly in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.childAFromJsonable( + loadTheFirstExpected("ChildA") + ).mustValue(); + + instance.aOnly = value; - expect(caught.path).toStrictEqual(".aOnly"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Child_b serialization fails on an integer outside the range representable in JSON " - + "at .number", + "Child_b refuses an integer outside the safe integer range " + + "at .number in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.childBFromJsonable( @@ -657,27 +737,52 @@ test( instance.number = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.number: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Child_b accepts the bounds of the safe integer range " + + "at .number in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.childBFromJsonable( + loadTheFirstExpected("ChildB") + ).mustValue(); - expect(caught.path).toStrictEqual(".number"); + instance.number = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .number", + "Something refuses an integer outside the safe integer range " + + "at .number in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -686,20 +791,45 @@ test( instance.number = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.number: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .number in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.number = value; - expect(caught.path).toStrictEqual(".number"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/assert_statements/expected_output/test/xmlization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/assert_statements/expected_output/test/xmlization.concreteClasses.spec.ts index d6951217e..869051551 100644 --- a/dev/test_data/main/typescript/expected/assert_statements/expected_output/test/xmlization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/assert_statements/expected_output/test/xmlization.concreteClasses.spec.ts @@ -230,5 +230,71 @@ test("Something XML deserialization fail", () => { } }); +/** + * Read the first recorded example of ChildA with the content + * of the element `xmlName` replaced by `text`. + */ +function readEitherWith( + xmlName: string, + text: string +): ReturnType { + const pths = Array.from( + TestCommon.findFilesBySuffixRecursively( + path.join( + TestCommon.TEST_DATA_DIR, + "Xml", + "Expected", + "childA" + ), + ".xml" + ) + ); + pths.sort(); + + expect(pths.length).toBeGreaterThan(0); + + const original = fs.readFileSync(pths[0], "utf-8"); + + const start = original.indexOf(`<${xmlName}>`) + xmlName.length + 2; + const end = original.indexOf(``); + + const patched = original.slice(0, start) + text + original.slice(end); + + return OurXmlization.fromXmlString(patched); +} + +/** + * Read the first recorded example of ChildA with the content + * of the element `xmlName` replaced by `text`, and expect no error. + */ +function readWith(xmlName: string, text: string): OurTypes.ChildA { + const instanceOrError = readEitherWith(xmlName, text); + expect(instanceOrError.error).toBeNull(); + + const casted = OurTypes.asChildA(instanceOrError.mustValue()); + if (casted === null) { + throw new Error(`Expected an instance of ChildA`); + } + + return casted; +} + +test("number read at the bounds of the safe integer range", () => { + for (const text of ["9007199254740991", "-9007199254740991"]) { + const instance = readWith("number", text); + expect(instance.number).toBe(Number(text)); + } +}); + +test("number refused outside the safe integer range", () => { + for (const text of ["9007199254740992", "-9007199254740992"]) { + const instanceOrError = readEitherWith("number", text); + expect(instanceOrError.error?.message).toStrictEqual( + `Expected an integer, but got ${text}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +}); + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/assignment_targets/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/assignment_targets/expected_output/src/jsonization.ts index ee3f9111c..452a00c87 100644 --- a/dev/test_data/main/typescript/expected/assignment_targets/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/assignment_targets/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/assignment_targets/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/assignment_targets/expected_output/src/xmlization.ts index abe0d125e..95ed37804 100644 --- a/dev/test_data/main/typescript/expected/assignment_targets/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/assignment_targets/expected_output/src/xmlization.ts @@ -117,38 +117,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -792,25 +760,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/collections_in_returns/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/collections_in_returns/expected_output/src/jsonization.ts index 69a474b15..740fc1c29 100644 --- a/dev/test_data/main/typescript/expected/collections_in_returns/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/collections_in_returns/expected_output/src/jsonization.ts @@ -472,36 +472,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/collections_in_returns/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/collections_in_returns/expected_output/src/xmlization.ts index 7a5e2b7ae..0a2ee5060 100644 --- a/dev/test_data/main/typescript/expected/collections_in_returns/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/collections_in_returns/expected_output/src/xmlization.ts @@ -118,38 +118,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -806,25 +774,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/comparisons/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/comparisons/expected_output/src/common.ts index 11a40830a..208c38cc4 100644 --- a/dev/test_data/main/typescript/expected/comparisons/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/comparisons/expected_output/src/common.ts @@ -503,5 +503,36 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/comparisons/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/comparisons/expected_output/src/jsonization.ts index fce453fb9..059fcbee8 100644 --- a/dev/test_data/main/typescript/expected/comparisons/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/comparisons/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -769,21 +782,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/comparisons/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/comparisons/expected_output/src/verification.ts index 58da1a9f5..364b65e61 100644 --- a/dev/test_data/main/typescript/expected/comparisons/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/comparisons/expected_output/src/verification.ts @@ -252,6 +252,22 @@ export function textFitsIntoCount( ); } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -299,6 +315,16 @@ class Verifier "Flag implies the other flag" ) } + + for (const error of verifyInteger(that.count)) { + error.path.prepend( + new PropertySegment( + that, + "count" + ) + ); + yield error; + } } } diff --git a/dev/test_data/main/typescript/expected/comparisons/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/comparisons/expected_output/src/xmlization.ts index c81539cb3..d235249e0 100644 --- a/dev/test_data/main/typescript/expected/comparisons/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/comparisons/expected_output/src/xmlization.ts @@ -98,17 +98,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -570,15 +566,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/comparisons/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/comparisons/expected_output/test/jsonization.concreteClasses.spec.ts index 98dec24c3..2320af472 100644 --- a/dev/test_data/main/typescript/expected/comparisons/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/comparisons/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -302,9 +303,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .count", + "Something refuses an integer outside the safe integer range " + + "at .count in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -313,20 +343,45 @@ test( instance.count = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.count: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .count in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.count = value; - expect(caught.path).toStrictEqual(".count"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/comparisons/expected_output/test/xmlization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/comparisons/expected_output/test/xmlization.concreteClasses.spec.ts index d88a0a31b..6a64caf43 100644 --- a/dev/test_data/main/typescript/expected/comparisons/expected_output/test/xmlization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/comparisons/expected_output/test/xmlization.concreteClasses.spec.ts @@ -90,7 +90,10 @@ test("Something XML deserialization fail", () => { * Read the first recorded example of Something with the content * of the element `xmlName` replaced by `text`. */ -function readWith(xmlName: string, text: string): OurTypes.Something { +function readEitherWith( + xmlName: string, + text: string +): ReturnType { const pths = Array.from( TestCommon.findFilesBySuffixRecursively( path.join( @@ -113,7 +116,15 @@ function readWith(xmlName: string, text: string): OurTypes.Something { const patched = original.slice(0, start) + text + original.slice(end); - const instanceOrError = OurXmlization.fromXmlString(patched); + return OurXmlization.fromXmlString(patched); +} + +/** + * Read the first recorded example of Something with the content + * of the element `xmlName` replaced by `text`, and expect no error. + */ +function readWith(xmlName: string, text: string): OurTypes.Something { + const instanceOrError = readEitherWith(xmlName, text); expect(instanceOrError.error).toBeNull(); const casted = OurTypes.asSomething(instanceOrError.mustValue()); @@ -204,5 +215,22 @@ test("flag read from 'false'", () => { expect(instance.flag).toBe(false); }); +test("count read at the bounds of the safe integer range", () => { + for (const text of ["9007199254740991", "-9007199254740991"]) { + const instance = readWith("count", text); + expect(instance.count).toBe(Number(text)); + } +}); + +test("count refused outside the safe integer range", () => { + for (const text of ["9007199254740992", "-9007199254740992"]) { + const instanceOrError = readEitherWith("count", text); + expect(instanceOrError.error?.message).toStrictEqual( + `Expected an integer, but got ${text}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +}); + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/conflict_with_utility_types/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/conflict_with_utility_types/expected_output/src/jsonization.ts index 4bc351e85..26fbf8da2 100644 --- a/dev/test_data/main/typescript/expected/conflict_with_utility_types/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/conflict_with_utility_types/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/conflict_with_utility_types/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/conflict_with_utility_types/expected_output/src/xmlization.ts index 9a1cafa73..a75c9af27 100644 --- a/dev/test_data/main/typescript/expected/conflict_with_utility_types/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/conflict_with_utility_types/expected_output/src/xmlization.ts @@ -114,38 +114,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -632,25 +600,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/common.ts index 11a40830a..208c38cc4 100644 --- a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/common.ts @@ -503,5 +503,36 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/jsonization.ts index cc576d099..ddf9cd117 100644 --- a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -769,21 +782,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/verification.ts index b5d266d83..3f4bbd820 100644 --- a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/verification.ts @@ -189,6 +189,22 @@ export class VerificationError { } } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -211,18 +227,6 @@ class Verifier yield error; } - if (!Number.isInteger(that.someInt)) { - const error = new VerificationError( - "Expected an integer, but got a floating-point number" - ); - error.path.prepend( - new PropertySegment( - that, - "someInt" - ) - ); - yield error; - } for (const error of verifyPositiveint(that.someInt)) { error.path.prepend( new PropertySegment( @@ -306,6 +310,11 @@ export function *verifyConstrainedbool( export function *verifyPositiveint( that: number ): IterableIterator { + if (!Number.isSafeInteger(that)) { + yield* verifyInteger(that); + return; + } + if (!(that > 0)) { yield new VerificationError( "Larger than zero" diff --git a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/xmlization.ts index 8b37b8123..fcecad3e4 100644 --- a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/src/xmlization.ts @@ -98,17 +98,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -570,15 +566,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/test/jsonization.concreteClasses.spec.ts index 59347c0e9..5e3341009 100644 --- a/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/constrained_primitives/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -302,9 +303,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .someInt", + "Something refuses an integer outside the safe integer range " + + "at .someInt in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -313,20 +343,45 @@ test( instance.someInt = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.someInt: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .someInt in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.someInt = value; - expect(caught.path).toStrictEqual(".someInt"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/custom_serialization_names/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/custom_serialization_names/expected_output/src/jsonization.ts index 7e6ca61ac..0da1e2f95 100644 --- a/dev/test_data/main/typescript/expected/custom_serialization_names/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/custom_serialization_names/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/custom_serialization_names/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/custom_serialization_names/expected_output/src/xmlization.ts index 5cb7ef737..06832af7c 100644 --- a/dev/test_data/main/typescript/expected/custom_serialization_names/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/custom_serialization_names/expected_output/src/xmlization.ts @@ -83,38 +83,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -466,25 +434,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/common.ts index 1ad845117..4e8a7101c 100644 --- a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/common.ts @@ -444,5 +444,36 @@ export function base64UrlDecode(text: string): Either { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/jsonization.ts index bf5908812..b2e2deea1 100644 --- a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -1503,21 +1516,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/verification.ts index f76e2d172..f173f1b0d 100644 --- a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/verification.ts @@ -189,6 +189,22 @@ export class VerificationError { } } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -206,21 +222,35 @@ class Verifier } *transformLeafWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.Leaf, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for Leaf. + for (const error of verifyInteger(that.value)) { + error.path.prepend( + new PropertySegment( + that, + "value" + ) + ); + yield error; + } } *transformBlossomWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.Blossom, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for Blossom. + for (const error of verifyInteger(that.value)) { + error.path.prepend( + new PropertySegment( + that, + "value" + ) + ); + yield error; + } } *transformPlainMarkerWithContext( diff --git a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/xmlization.ts index 39a8f4d57..5e9e7a9d5 100644 --- a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/src/xmlization.ts @@ -173,17 +173,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -1531,15 +1527,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/test/jsonization.concreteClasses.spec.ts index 1a8161b6c..bde05ee50 100644 --- a/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/deep_class_hierarchy/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -1017,9 +1018,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Leaf serialization fails on an integer outside the range representable in JSON " - + "at .value", + "Leaf refuses an integer outside the safe integer range " + + "at .value in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.leafFromJsonable( @@ -1028,27 +1058,52 @@ test( instance.value = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.value: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Leaf accepts the bounds of the safe integer range " + + "at .value in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.leafFromJsonable( + loadTheFirstExpected("Leaf") + ).mustValue(); + + instance.value = value; - expect(caught.path).toStrictEqual(".value"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Blossom serialization fails on an integer outside the range representable in JSON " - + "at .value", + "Blossom refuses an integer outside the safe integer range " + + "at .value in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.blossomFromJsonable( @@ -1057,20 +1112,45 @@ test( instance.value = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.value: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Blossom accepts the bounds of the safe integer range " + + "at .value in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.blossomFromJsonable( + loadTheFirstExpected("Blossom") + ).mustValue(); - expect(caught.path).toStrictEqual(".value"); + instance.value = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/empty_class/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/empty_class/expected_output/src/jsonization.ts index 484b33d95..cc4684ce1 100644 --- a/dev/test_data/main/typescript/expected/empty_class/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/empty_class/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/empty_class/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/empty_class/expected_output/src/xmlization.ts index 8e6e02065..4d74031aa 100644 --- a/dev/test_data/main/typescript/expected/empty_class/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/empty_class/expected_output/src/xmlization.ts @@ -67,38 +67,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -402,25 +370,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/enum/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/enum/expected_output/src/jsonization.ts index 41e2bec91..9ebd39eef 100644 --- a/dev/test_data/main/typescript/expected/enum/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/enum/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/enum/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/enum/expected_output/src/xmlization.ts index 449dec249..195be7353 100644 --- a/dev/test_data/main/typescript/expected/enum/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/enum/expected_output/src/xmlization.ts @@ -114,38 +114,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -498,25 +466,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/float_only/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/float_only/expected_output/src/jsonization.ts index f35152e46..3adffe1c5 100644 --- a/dev/test_data/main/typescript/expected/float_only/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/float_only/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/float_only/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/float_only/expected_output/src/xmlization.ts index 5e5a9c2a8..436e435af 100644 --- a/dev/test_data/main/typescript/expected/float_only/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/float_only/expected_output/src/xmlization.ts @@ -88,38 +88,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -718,25 +686,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/for_statements/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/for_statements/expected_output/src/common.ts index 2e04d4ae3..182707a14 100644 --- a/dev/test_data/main/typescript/expected/for_statements/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/for_statements/expected_output/src/common.ts @@ -544,5 +544,36 @@ export function floorMod(dividend: number, divisor: number): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/for_statements/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/for_statements/expected_output/src/jsonization.ts index 9dc76ef19..35833ed6f 100644 --- a/dev/test_data/main/typescript/expected/for_statements/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/for_statements/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -885,21 +898,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/for_statements/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/for_statements/expected_output/src/verification.ts index 2eb35cfab..3e64341b2 100644 --- a/dev/test_data/main/typescript/expected/for_statements/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/for_statements/expected_output/src/verification.ts @@ -421,6 +421,41 @@ export function textsBeforeStopAreFew( return count < 3; } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + +/** + * Verify the items of `that` recursively. + */ +function *verify_ListOf_int( + that: ReadonlyArray +): IterableIterator { + for (let i = 0; i < that.length; i++) { + for (const error of verifyInteger(that[i])) { + error.path.prepend( + new IndexSegment( + that, + i + ) + ); + yield error; + } + } +} + /** * Verify the items of `that` recursively. */ @@ -539,6 +574,16 @@ class Verifier } if (context === true) { + for (const error of verify_ListOf_int(that.numbers)) { + error.path.prepend( + new PropertySegment( + that, + "numbers" + ) + ); + yield error; + } + for (const error of verify_ListOf_class(that.items)) { error.path.prepend( new PropertySegment( diff --git a/dev/test_data/main/typescript/expected/for_statements/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/for_statements/expected_output/src/xmlization.ts index 305cbeca3..bc240b6e6 100644 --- a/dev/test_data/main/typescript/expected/for_statements/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/for_statements/expected_output/src/xmlization.ts @@ -131,17 +131,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -802,15 +798,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/for_statements/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/for_statements/expected_output/test/jsonization.concreteClasses.spec.ts index 4c384411c..34d94d3ce 100644 --- a/dev/test_data/main/typescript/expected/for_statements/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/for_statements/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -445,9 +446,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .numbers[1]", + "Something refuses an integer outside the safe integer range " + + "at .numbers[1] in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -456,20 +486,45 @@ test( instance.numbers = [0, value] as typeof instance.numbers; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.numbers[1]: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .numbers[1] in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".numbers[1]"); + instance.numbers = [0, value] as typeof instance.numbers; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/if_statements/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/if_statements/expected_output/src/common.ts index 2e04d4ae3..182707a14 100644 --- a/dev/test_data/main/typescript/expected/if_statements/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/if_statements/expected_output/src/common.ts @@ -544,5 +544,36 @@ export function floorMod(dividend: number, divisor: number): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/if_statements/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/if_statements/expected_output/src/jsonization.ts index 4e7a79db6..5f60bdd21 100644 --- a/dev/test_data/main/typescript/expected/if_statements/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/if_statements/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -1375,21 +1388,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/if_statements/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/if_statements/expected_output/src/verification.ts index f6fab7a8e..b380e7a39 100644 --- a/dev/test_data/main/typescript/expected/if_statements/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/if_statements/expected_output/src/verification.ts @@ -537,6 +537,22 @@ export function hasMarkerInTree( return false; } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify the items of `that` recursively. */ @@ -573,21 +589,35 @@ class Verifier } *transformChildAWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.ChildA, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for ChildA. + for (const error of verifyInteger(that.aOnly)) { + error.path.prepend( + new PropertySegment( + that, + "aOnly" + ) + ); + yield error; + } } *transformChildBWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.ChildB, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for ChildB. + for (const error of verifyInteger(that.bOnly)) { + error.path.prepend( + new PropertySegment( + that, + "bOnly" + ) + ); + yield error; + } } *transformContainerWithContext( @@ -740,6 +770,16 @@ class Verifier ) } + for (const error of verifyInteger(that.number)) { + error.path.prepend( + new PropertySegment( + that, + "number" + ) + ); + yield error; + } + if (context === true) { for (const error of verify(that.item)) { error.path.prepend( diff --git a/dev/test_data/main/typescript/expected/if_statements/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/if_statements/expected_output/src/xmlization.ts index 12f5ebe9a..c440aa73a 100644 --- a/dev/test_data/main/typescript/expected/if_statements/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/if_statements/expected_output/src/xmlization.ts @@ -206,17 +206,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -1334,15 +1330,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/if_statements/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/if_statements/expected_output/test/jsonization.concreteClasses.spec.ts index e947ce1ed..13da23c81 100644 --- a/dev/test_data/main/typescript/expected/if_statements/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/if_statements/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -874,9 +875,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Child_a serialization fails on an integer outside the range representable in JSON " - + "at .aOnly", + "Child_a refuses an integer outside the safe integer range " + + "at .aOnly in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.childAFromJsonable( @@ -885,27 +915,52 @@ test( instance.aOnly = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.aOnly: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Child_a accepts the bounds of the safe integer range " + + "at .aOnly in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.childAFromJsonable( + loadTheFirstExpected("ChildA") + ).mustValue(); - expect(caught.path).toStrictEqual(".aOnly"); + instance.aOnly = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Child_b serialization fails on an integer outside the range representable in JSON " - + "at .bOnly", + "Child_b refuses an integer outside the safe integer range " + + "at .bOnly in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.childBFromJsonable( @@ -914,27 +969,52 @@ test( instance.bOnly = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.bOnly: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Child_b accepts the bounds of the safe integer range " + + "at .bOnly in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.childBFromJsonable( + loadTheFirstExpected("ChildB") + ).mustValue(); - expect(caught.path).toStrictEqual(".bOnly"); + instance.bOnly = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .number", + "Something refuses an integer outside the safe integer range " + + "at .number in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -943,20 +1023,45 @@ test( instance.number = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.number: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .number in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".number"); + instance.number = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/implementation_specific/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/implementation_specific/expected_output/src/jsonization.ts index aa51f35d1..1ad77f3f9 100644 --- a/dev/test_data/main/typescript/expected/implementation_specific/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/implementation_specific/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/implementation_specific/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/implementation_specific/expected_output/src/xmlization.ts index 4ee861008..98b69bee4 100644 --- a/dev/test_data/main/typescript/expected/implementation_specific/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/implementation_specific/expected_output/src/xmlization.ts @@ -192,38 +192,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -987,25 +955,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/common.ts index 4062f3795..f5d71a34a 100644 --- a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/common.ts @@ -485,5 +485,36 @@ export function floorMod(dividend: number, divisor: number): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/jsonization.ts index 3f50a7375..a33975085 100644 --- a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -744,21 +757,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/verification.ts index cdc08c940..c6704d081 100644 --- a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/verification.ts @@ -239,11 +239,24 @@ export function noNumberIsSeven( /** * Check the ranges over the integers with a length as a bound. + * + * @remarks + * + * 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. */ export function noSevenFrom( numbers: Array, start: number ): boolean { + if (start < 0) { + return false; + } + if (start >= numbers.length) { + return true; + } for (let i = start; i < numbers.length; i++) { const number = OurCommon.at(numbers, i); if (number == 7) { @@ -303,6 +316,41 @@ export function setCounts( return true; } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + +/** + * Verify the items of `that` recursively. + */ +function *verify_ListOf_int( + that: ReadonlyArray +): IterableIterator { + for (let i = 0; i < that.length; i++) { + for (const error of verifyInteger(that[i])) { + error.path.prepend( + new IndexSegment( + that, + i + ) + ); + yield error; + } + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -312,7 +360,6 @@ class Verifier > { *transformSomethingWithContext( that: OurTypes.Something, - // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { if (!( @@ -368,6 +415,40 @@ class Verifier "No number is seven" ) } + + for (const error of verifyInteger(that.count)) { + error.path.prepend( + new PropertySegment( + that, + "count" + ) + ); + yield error; + } + + if (that.maybeCount !== null) { + for (const error of verifyInteger(that.maybeCount)) { + error.path.prepend( + new PropertySegment( + that, + "maybeCount" + ) + ); + yield error; + } + } + + if (context === true) { + for (const error of verify_ListOf_int(that.numbers)) { + error.path.prepend( + new PropertySegment( + that, + "numbers" + ) + ); + yield error; + } + } } } diff --git a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/xmlization.ts index 5dd92a16b..539329b2f 100644 --- a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/src/xmlization.ts @@ -101,17 +101,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -621,15 +617,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/test/jsonization.concreteClasses.spec.ts index 12ebd95a4..fb830c6a2 100644 --- a/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/int_length_interplay/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -302,9 +303,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .numbers[1]", + "Something refuses an integer outside the safe integer range " + + "at .numbers[1] in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -313,27 +343,52 @@ test( instance.numbers = [0, value] as typeof instance.numbers; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.numbers[1]: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .numbers[1] in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.numbers = [0, value] as typeof instance.numbers; - expect(caught.path).toStrictEqual(".numbers[1]"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .count", + "Something refuses an integer outside the safe integer range " + + "at .count in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -342,20 +397,45 @@ test( instance.count = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.count: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .count in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".count"); + instance.count = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/common.ts index 40011a62e..c34358b04 100644 --- a/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/common.ts @@ -580,6 +580,10 @@ export function sliceStr(text: string, start: number, end?: number): string { * `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 @@ -610,17 +614,16 @@ export function parseSafeInt(text: string): number { const digit = code - 48; // NOTE (mristin): - // We check the range before we accumulate so that the arithmetic stays - // exact. The number 900719925474099 is Number.MAX_SAFE_INTEGER divided by 10, - // and 1 is its last digit. - if (value > 900719925474099 || (value === 900719925474099 && digit > 1)) { + // While the result stays a safe integer, the arithmetic is exact. Once + // the exact result leaves the safe range, the rounded one leaves it as + // well, as 2^53 is representable, so we stop before we lose any precision. + value = value * 10 + digit; + if (!Number.isSafeInteger(value)) { throw new Error( "Expected a safe integer, but got a text out of its range: " + JSON.stringify(text) ); } - - value = value * 10 + digit; } // NOTE (mristin): diff --git a/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/jsonization.ts index c7a47228b..79732fc51 100644 --- a/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/xmlization.ts index c0732eb4d..367ebac03 100644 --- a/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/int_parsing/expected_output/src/xmlization.ts @@ -84,38 +84,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -560,25 +528,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/is_instance/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/is_instance/expected_output/src/jsonization.ts index bd55532fd..85a64b7e2 100644 --- a/dev/test_data/main/typescript/expected/is_instance/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/is_instance/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/is_instance/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/is_instance/expected_output/src/xmlization.ts index 11af620cf..2a7431e7d 100644 --- a/dev/test_data/main/typescript/expected/is_instance/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/is_instance/expected_output/src/xmlization.ts @@ -160,38 +160,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -1795,25 +1763,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/json_types/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/json_types/expected_output/src/jsonization.ts index b72c77aa4..8bb9b2ad9 100644 --- a/dev/test_data/main/typescript/expected/json_types/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/json_types/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * @@ -794,7 +764,7 @@ export class SerializationError extends Error { * @param jsonable - to be parsed * @returns the parsed value, or an error */ -function jsonValueFromJsonable( +export function jsonValueFromJsonable( jsonable: JsonValue ): OurCommon.Either { if (jsonable === null) { @@ -879,7 +849,7 @@ function jsonValueFromJsonable( * @param jsonable - to be parsed * @returns the parsed array, or an error */ -function jsonArrayFromJsonable( +export function jsonArrayFromJsonable( jsonable: JsonValue ): OurCommon.Either { if (!Array.isArray(jsonable)) { @@ -906,7 +876,7 @@ function jsonArrayFromJsonable( * @param jsonable - to be parsed * @returns the parsed object, or an error */ -function jsonObjectFromJsonable( +export function jsonObjectFromJsonable( jsonable: JsonValue ): OurCommon.Either { if ( @@ -943,13 +913,13 @@ function jsonObjectFromJsonable( * @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 { if (that === null) { return null; } diff --git a/dev/test_data/main/typescript/expected/json_types/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/json_types/expected_output/src/xmlization.ts index a309936a6..3695d89a3 100644 --- a/dev/test_data/main/typescript/expected/json_types/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/json_types/expected_output/src/xmlization.ts @@ -93,38 +93,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -617,25 +585,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/json_types_in_collections/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/json_types_in_collections/expected_output/src/jsonization.ts index fab07e218..72b36660e 100644 --- a/dev/test_data/main/typescript/expected/json_types_in_collections/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/json_types_in_collections/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * @@ -882,7 +852,7 @@ export class SerializationError extends Error { * @param jsonable - to be parsed * @returns the parsed value, or an error */ -function jsonValueFromJsonable( +export function jsonValueFromJsonable( jsonable: JsonValue ): OurCommon.Either { if (jsonable === null) { @@ -967,7 +937,7 @@ function jsonValueFromJsonable( * @param jsonable - to be parsed * @returns the parsed array, or an error */ -function jsonArrayFromJsonable( +export function jsonArrayFromJsonable( jsonable: JsonValue ): OurCommon.Either { if (!Array.isArray(jsonable)) { @@ -994,7 +964,7 @@ function jsonArrayFromJsonable( * @param jsonable - to be parsed * @returns the parsed object, or an error */ -function jsonObjectFromJsonable( +export function jsonObjectFromJsonable( jsonable: JsonValue ): OurCommon.Either { if ( @@ -1031,13 +1001,13 @@ function jsonObjectFromJsonable( * @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 { if (that === null) { return null; } diff --git a/dev/test_data/main/typescript/expected/json_types_in_collections/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/json_types_in_collections/expected_output/src/xmlization.ts index 1d13cafeb..a752272f2 100644 --- a/dev/test_data/main/typescript/expected/json_types_in_collections/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/json_types_in_collections/expected_output/src/xmlization.ts @@ -97,38 +97,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -863,25 +831,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/json_types_in_invariants/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/json_types_in_invariants/expected_output/src/jsonization.ts index e18466f31..53bd81ca0 100644 --- a/dev/test_data/main/typescript/expected/json_types_in_invariants/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/json_types_in_invariants/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * @@ -745,7 +715,7 @@ export class SerializationError extends Error { * @param jsonable - to be parsed * @returns the parsed value, or an error */ -function jsonValueFromJsonable( +export function jsonValueFromJsonable( jsonable: JsonValue ): OurCommon.Either { if (jsonable === null) { @@ -830,7 +800,7 @@ function jsonValueFromJsonable( * @param jsonable - to be parsed * @returns the parsed array, or an error */ -function jsonArrayFromJsonable( +export function jsonArrayFromJsonable( jsonable: JsonValue ): OurCommon.Either { if (!Array.isArray(jsonable)) { @@ -857,7 +827,7 @@ function jsonArrayFromJsonable( * @param jsonable - to be parsed * @returns the parsed object, or an error */ -function jsonObjectFromJsonable( +export function jsonObjectFromJsonable( jsonable: JsonValue ): OurCommon.Either { if ( @@ -894,13 +864,13 @@ function jsonObjectFromJsonable( * @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 { if (that === null) { return null; } diff --git a/dev/test_data/main/typescript/expected/json_types_in_invariants/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/json_types_in_invariants/expected_output/src/xmlization.ts index 78cae5fc1..4f93080ee 100644 --- a/dev/test_data/main/typescript/expected/json_types_in_invariants/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/json_types_in_invariants/expected_output/src/xmlization.ts @@ -93,38 +93,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -533,25 +501,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/common.ts index 11a40830a..208c38cc4 100644 --- a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/common.ts @@ -503,5 +503,36 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/jsonization.ts index 69b2049a6..43921a2ea 100644 --- a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -1126,21 +1139,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/verification.ts index 02f1feeb1..c77afa8cc 100644 --- a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/verification.ts @@ -269,6 +269,22 @@ export function isInLocalCopyOfSimples( return copied.includes(simple); } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify the items of `that` recursively. */ @@ -305,12 +321,19 @@ class Verifier } *transformAnotherItemWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.AnotherItem, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for AnotherItem. + for (const error of verifyInteger(that.serialNumber)) { + error.path.prepend( + new PropertySegment( + that, + "serialNumber" + ) + ); + yield error; + } } *transformSimpleWithContext( diff --git a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/xmlization.ts index 4aa48805f..c1662d285 100644 --- a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/src/xmlization.ts @@ -175,17 +175,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -995,15 +991,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/test/jsonization.concreteClasses.spec.ts index b545e621d..aa593c180 100644 --- a/dev/test_data/main/typescript/expected/list_of_classes/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/list_of_classes/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -731,9 +732,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Another_item serialization fails on an integer outside the range representable in JSON " - + "at .serialNumber", + "Another_item refuses an integer outside the safe integer range " + + "at .serialNumber in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.anotherItemFromJsonable( @@ -742,20 +772,45 @@ test( instance.serialNumber = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.serialNumber: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Another_item accepts the bounds of the safe integer range " + + "at .serialNumber in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.anotherItemFromJsonable( + loadTheFirstExpected("AnotherItem") + ).mustValue(); - expect(caught.path).toStrictEqual(".serialNumber"); + instance.serialNumber = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/list_of_constrained_primitives/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/list_of_constrained_primitives/expected_output/src/jsonization.ts index 136b2f867..e289f0fdf 100644 --- a/dev/test_data/main/typescript/expected/list_of_constrained_primitives/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/list_of_constrained_primitives/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/list_of_constrained_primitives/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/list_of_constrained_primitives/expected_output/src/xmlization.ts index 1dd3942e2..9285a2b2f 100644 --- a/dev/test_data/main/typescript/expected/list_of_constrained_primitives/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/list_of_constrained_primitives/expected_output/src/xmlization.ts @@ -85,38 +85,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -489,25 +457,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/list_of_enums/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/list_of_enums/expected_output/src/jsonization.ts index bf9441252..ebb18d7d9 100644 --- a/dev/test_data/main/typescript/expected/list_of_enums/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/list_of_enums/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/list_of_enums/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/list_of_enums/expected_output/src/xmlization.ts index 849947e7c..be425ea29 100644 --- a/dev/test_data/main/typescript/expected/list_of_enums/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/list_of_enums/expected_output/src/xmlization.ts @@ -116,38 +116,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -542,25 +510,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/common.ts index 11a40830a..208c38cc4 100644 --- a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/common.ts @@ -503,5 +503,36 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/jsonization.ts index 61683afc0..29a04fc1f 100644 --- a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -774,21 +787,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/verification.ts index 0b220bea2..b2d0208f9 100644 --- a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/verification.ts @@ -331,6 +331,41 @@ export function containsDot( return text.includes("."); } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + +/** + * Verify the items of `that` recursively. + */ +function *verify_ListOf_int( + that: ReadonlyArray +): IterableIterator { + for (let i = 0; i < that.length; i++) { + for (const error of verifyInteger(that[i])) { + error.path.prepend( + new IndexSegment( + that, + i + ) + ); + yield error; + } + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -340,7 +375,6 @@ class Verifier > { *transformSomethingWithContext( that: OurTypes.Something, - // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { if (!( @@ -447,6 +481,18 @@ class Verifier "Some floats must be unique." ) } + + if (context === true) { + for (const error of verify_ListOf_int(that.someInts)) { + error.path.prepend( + new PropertySegment( + that, + "someInts" + ) + ); + yield error; + } + } } } diff --git a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/xmlization.ts index 9b6b3b143..07e700d50 100644 --- a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/src/xmlization.ts @@ -100,17 +100,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -782,15 +778,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/test/jsonization.concreteClasses.spec.ts index 0e5cacfbf..c8d6d6a36 100644 --- a/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/list_of_primitives/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -302,9 +303,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .someInts[1]", + "Something refuses an integer outside the safe integer range " + + "at .someInts[1] in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -313,20 +343,45 @@ test( instance.someInts = [0, value] as typeof instance.someInts; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.someInts[1]: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .someInts[1] in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.someInts = [0, value] as typeof instance.someInts; - expect(caught.path).toStrictEqual(".someInts[1]"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/method_references/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/method_references/expected_output/src/common.ts index 11a40830a..208c38cc4 100644 --- a/dev/test_data/main/typescript/expected/method_references/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/method_references/expected_output/src/common.ts @@ -503,5 +503,36 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/method_references/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/method_references/expected_output/src/jsonization.ts index 77265a9bb..2f31f7dfd 100644 --- a/dev/test_data/main/typescript/expected/method_references/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/method_references/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -780,21 +793,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/method_references/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/method_references/expected_output/src/verification.ts index 7fe46c557..ab29b0ffe 100644 --- a/dev/test_data/main/typescript/expected/method_references/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/method_references/expected_output/src/verification.ts @@ -202,6 +202,22 @@ export function isDescribed( return OurCommon.lenStr(text) > 0; } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -219,6 +235,16 @@ class Verifier "The size must be positive." ) } + + for (const error of verifyInteger(that.size)) { + error.path.prepend( + new PropertySegment( + that, + "size" + ) + ); + yield error; + } } } diff --git a/dev/test_data/main/typescript/expected/method_references/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/method_references/expected_output/src/xmlization.ts index 3aaea5dc2..340acd465 100644 --- a/dev/test_data/main/typescript/expected/method_references/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/method_references/expected_output/src/xmlization.ts @@ -173,17 +173,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -622,15 +618,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/method_references/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/method_references/expected_output/test/jsonization.concreteClasses.spec.ts index d2cd7b2d7..6fbce5f2f 100644 --- a/dev/test_data/main/typescript/expected/method_references/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/method_references/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -302,9 +303,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Box serialization fails on an integer outside the range representable in JSON " - + "at .size", + "Box refuses an integer outside the safe integer range " + + "at .size in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.boxFromJsonable( @@ -313,20 +343,45 @@ test( instance.size = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.size: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Box accepts the bounds of the safe integer range " + + "at .size in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.boxFromJsonable( + loadTheFirstExpected("Box") + ).mustValue(); - expect(caught.path).toStrictEqual(".size"); + instance.size = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/method_references/expected_output/test/xmlization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/method_references/expected_output/test/xmlization.concreteClasses.spec.ts index 5d72ca30f..d3c231e18 100644 --- a/dev/test_data/main/typescript/expected/method_references/expected_output/test/xmlization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/method_references/expected_output/test/xmlization.concreteClasses.spec.ts @@ -86,5 +86,71 @@ test("Box XML deserialization fail", () => { } }); +/** + * Read the first recorded example of Box with the content + * of the element `xmlName` replaced by `text`. + */ +function readEitherWith( + xmlName: string, + text: string +): ReturnType { + const pths = Array.from( + TestCommon.findFilesBySuffixRecursively( + path.join( + TestCommon.TEST_DATA_DIR, + "Xml", + "Expected", + "box" + ), + ".xml" + ) + ); + pths.sort(); + + expect(pths.length).toBeGreaterThan(0); + + const original = fs.readFileSync(pths[0], "utf-8"); + + const start = original.indexOf(`<${xmlName}>`) + xmlName.length + 2; + const end = original.indexOf(``); + + const patched = original.slice(0, start) + text + original.slice(end); + + return OurXmlization.fromXmlString(patched); +} + +/** + * Read the first recorded example of Box with the content + * of the element `xmlName` replaced by `text`, and expect no error. + */ +function readWith(xmlName: string, text: string): OurTypes.Box { + const instanceOrError = readEitherWith(xmlName, text); + expect(instanceOrError.error).toBeNull(); + + const casted = OurTypes.asBox(instanceOrError.mustValue()); + if (casted === null) { + throw new Error(`Expected an instance of Box`); + } + + return casted; +} + +test("size read at the bounds of the safe integer range", () => { + for (const text of ["9007199254740991", "-9007199254740991"]) { + const instance = readWith("size", text); + expect(instance.size).toBe(Number(text)); + } +}); + +test("size refused outside the safe integer range", () => { + for (const text of ["9007199254740992", "-9007199254740992"]) { + const instanceOrError = readEitherWith("size", text); + expect(instanceOrError.error?.message).toStrictEqual( + `Expected an integer, but got ${text}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +}); + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/nested_any_in_all/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/nested_any_in_all/expected_output/src/jsonization.ts index c2ea5d09b..b00c1dd58 100644 --- a/dev/test_data/main/typescript/expected/nested_any_in_all/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/nested_any_in_all/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/nested_any_in_all/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/nested_any_in_all/expected_output/src/xmlization.ts index 061009e2e..839438ff7 100644 --- a/dev/test_data/main/typescript/expected/nested_any_in_all/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/nested_any_in_all/expected_output/src/xmlization.ts @@ -161,38 +161,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -1221,25 +1189,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/common.ts index 0456045ec..d3721acc6 100644 --- a/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/common.ts @@ -578,6 +578,37 @@ export function compareByCodePoints(that: string, other: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + /** * Compare `that` and `other` so that `false` comes before `true`. * diff --git a/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/jsonization.ts index 831a60fde..cb2cdc471 100644 --- a/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/jsonization.ts @@ -484,18 +484,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -1541,21 +1554,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/verification.ts index 340a4d37a..6f62a7711 100644 --- a/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/verification.ts @@ -264,6 +264,60 @@ export function resetCorner( return true; } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + +/** + * Verify the items of `that` recursively. + */ +function *verify_ListOf_ListOf_int( + that: ReadonlyArray> +): IterableIterator { + for (let i = 0; i < that.length; i++) { + for (const error of verify_ListOf_int(that[i])) { + error.path.prepend( + new IndexSegment( + that, + i + ) + ); + yield error; + } + } +} + +/** + * Verify the items of `that` recursively. + */ +function *verify_ListOf_int( + that: ReadonlyArray +): IterableIterator { + for (let i = 0; i < that.length; i++) { + for (const error of verifyInteger(that[i])) { + error.path.prepend( + new IndexSegment( + that, + i + ) + ); + yield error; + } + } +} + /** * Verify the items of `that` recursively. */ @@ -377,6 +431,16 @@ function *verify_ListOf_class( function *verify_TupleOf2_int_Code( that: readonly [number, string] ): IterableIterator { + for (const error of verifyInteger(that[0])) { + error.path.prepend( + new IndexSegment( + that, + 0 + ) + ); + yield error; + } + for (const error of verifyCode(that[1])) { error.path.prepend( new IndexSegment( @@ -427,12 +491,19 @@ class Verifier } *transformAnotherItemWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.AnotherItem, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for AnotherItem. + for (const error of verifyInteger(that.serialNumber)) { + error.path.prepend( + new PropertySegment( + that, + "serialNumber" + ) + ); + yield error; + } } *transformSomethingWithContext( @@ -527,6 +598,16 @@ class Verifier } if (context === true) { + for (const error of verify_ListOf_ListOf_int(that.grid)) { + error.path.prepend( + new PropertySegment( + that, + "grid" + ) + ); + yield error; + } + for (const error of verify_ListOf_ListOf_TupleOf2_PositiveInt_SetOf_str( that.rowsOfPairs) ) { @@ -603,6 +684,11 @@ export function *verify( export function *verifyPositiveInt( that: number ): IterableIterator { + if (!Number.isSafeInteger(that)) { + yield* verifyInteger(that); + return; + } + if (!(that > 0)) { yield new VerificationError( "Positive int must be positive." diff --git a/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/xmlization.ts index 05692a222..55fcaddfe 100644 --- a/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/nested_collections/expected_output/src/xmlization.ts @@ -210,17 +210,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -1805,15 +1801,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/nested_collections/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/nested_collections/expected_output/test/jsonization.concreteClasses.spec.ts index faa7b6c82..3b6bdba86 100644 --- a/dev/test_data/main/typescript/expected/nested_collections/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/nested_collections/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -588,9 +589,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Another_item serialization fails on an integer outside the range representable in JSON " - + "at .serialNumber", + "Another_item refuses an integer outside the safe integer range " + + "at .serialNumber in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.anotherItemFromJsonable( @@ -599,20 +629,45 @@ test( instance.serialNumber = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.serialNumber: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Another_item accepts the bounds of the safe integer range " + + "at .serialNumber in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.anotherItemFromJsonable( + loadTheFirstExpected("AnotherItem") + ).mustValue(); - expect(caught.path).toStrictEqual(".serialNumber"); + instance.serialNumber = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/common.ts index 11a40830a..208c38cc4 100644 --- a/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/common.ts @@ -503,5 +503,36 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/jsonization.ts index 0baf1f20d..8d4962f27 100644 --- a/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -891,21 +904,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/verification.ts index 04520cd7f..f78fb8e0d 100644 --- a/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/verification.ts @@ -292,6 +292,22 @@ export function noteIsShort( ); } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -354,6 +370,18 @@ class Verifier ) } + if (that.optionalNumber !== null) { + for (const error of verifyInteger(that.optionalNumber)) { + error.path.prepend( + new PropertySegment( + that, + "optionalNumber" + ) + ); + yield error; + } + } + if (context === true) { for (const error of verify(that.item)) { error.path.prepend( diff --git a/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/xmlization.ts index aa6299e22..334f70404 100644 --- a/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/optional_arguments/expected_output/src/xmlization.ts @@ -132,17 +132,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -796,15 +792,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/common.ts index 1ad845117..4e8a7101c 100644 --- a/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/common.ts @@ -444,5 +444,36 @@ export function base64UrlDecode(text: string): Either { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/jsonization.ts index cc576d099..ddf9cd117 100644 --- a/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -769,21 +782,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/verification.ts index 94e8c5ff6..5efbee0d4 100644 --- a/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/verification.ts @@ -189,6 +189,22 @@ export class VerificationError { } } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -197,12 +213,19 @@ class Verifier boolean, IterableIterator > { *transformSomethingWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.Something, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for Something. + for (const error of verifyInteger(that.someInt)) { + error.path.prepend( + new PropertySegment( + that, + "someInt" + ) + ); + yield error; + } } } diff --git a/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/xmlization.ts index 8b37b8123..fcecad3e4 100644 --- a/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/primitive_types/expected_output/src/xmlization.ts @@ -98,17 +98,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -570,15 +566,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/primitive_types/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/primitive_types/expected_output/test/jsonization.concreteClasses.spec.ts index 59347c0e9..5e3341009 100644 --- a/dev/test_data/main/typescript/expected/primitive_types/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/primitive_types/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -302,9 +303,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .someInt", + "Something refuses an integer outside the safe integer range " + + "at .someInt in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -313,20 +343,45 @@ test( instance.someInt = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.someInt: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .someInt in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.someInt = value; - expect(caught.path).toStrictEqual(".someInt"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/primitive_types/expected_output/test/xmlization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/primitive_types/expected_output/test/xmlization.concreteClasses.spec.ts index 8ce39719b..b330bf415 100644 --- a/dev/test_data/main/typescript/expected/primitive_types/expected_output/test/xmlization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/primitive_types/expected_output/test/xmlization.concreteClasses.spec.ts @@ -90,7 +90,10 @@ test("Something XML deserialization fail", () => { * Read the first recorded example of Something with the content * of the element `xmlName` replaced by `text`. */ -function readWith(xmlName: string, text: string): OurTypes.Something { +function readEitherWith( + xmlName: string, + text: string +): ReturnType { const pths = Array.from( TestCommon.findFilesBySuffixRecursively( path.join( @@ -113,7 +116,15 @@ function readWith(xmlName: string, text: string): OurTypes.Something { const patched = original.slice(0, start) + text + original.slice(end); - const instanceOrError = OurXmlization.fromXmlString(patched); + return OurXmlization.fromXmlString(patched); +} + +/** + * Read the first recorded example of Something with the content + * of the element `xmlName` replaced by `text`, and expect no error. + */ +function readWith(xmlName: string, text: string): OurTypes.Something { + const instanceOrError = readEitherWith(xmlName, text); expect(instanceOrError.error).toBeNull(); const casted = OurTypes.asSomething(instanceOrError.mustValue()); @@ -236,5 +247,22 @@ test("someBytes read from ''", () => { expect(instance.someBytes).toEqual(new Uint8Array([])); }); +test("someInt read at the bounds of the safe integer range", () => { + for (const text of ["9007199254740991", "-9007199254740991"]) { + const instance = readWith("someInt", text); + expect(instance.someInt).toBe(Number(text)); + } +}); + +test("someInt refused outside the safe integer range", () => { + for (const text of ["9007199254740992", "-9007199254740992"]) { + const instanceOrError = readEitherWith("someInt", text); + expect(instanceOrError.error?.message).toStrictEqual( + `Expected an integer, but got ${text}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]" + ); + } +}); + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/problematic_keywords/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/problematic_keywords/expected_output/src/jsonization.ts index 8c54b0789..d532bf576 100644 --- a/dev/test_data/main/typescript/expected/problematic_keywords/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/problematic_keywords/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/problematic_keywords/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/problematic_keywords/expected_output/src/xmlization.ts index 4327b7473..4b52e9f8a 100644 --- a/dev/test_data/main/typescript/expected/problematic_keywords/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/problematic_keywords/expected_output/src/xmlization.ts @@ -83,38 +83,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -535,25 +503,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/sets/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/sets/expected_output/src/common.ts index 37b2ddc15..f48fba020 100644 --- a/dev/test_data/main/typescript/expected/sets/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/sets/expected_output/src/common.ts @@ -578,6 +578,37 @@ export function compareByCodePoints(that: string, other: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + /** * Give a new set of the items which are both in `that` and in `other`. */ diff --git a/dev/test_data/main/typescript/expected/sets/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/sets/expected_output/src/jsonization.ts index 177c51d2b..8433a04bb 100644 --- a/dev/test_data/main/typescript/expected/sets/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/sets/expected_output/src/jsonization.ts @@ -484,18 +484,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -1174,21 +1187,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/sets/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/sets/expected_output/src/verification.ts index 7be818872..619d33605 100644 --- a/dev/test_data/main/typescript/expected/sets/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/sets/expected_output/src/verification.ts @@ -475,6 +475,41 @@ export function someKindIsNotSpecial( ); } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + +/** + * Verify the items of `that` recursively. + */ +function *verify_ListOf_int( + that: ReadonlyArray +): IterableIterator { + for (let i = 0; i < that.length; i++) { + for (const error of verifyInteger(that[i])) { + error.path.prepend( + new IndexSegment( + that, + i + ) + ); + yield error; + } + } +} + /** * Verify the items of `that` recursively. */ @@ -494,6 +529,26 @@ function *verify_ListOf_Code( } } +/** + * Verify the items of `that` recursively. + */ +function *verify_SetOf_int( + that: ReadonlySet +): IterableIterator { + const sortedThat = Array.from(that).sort(OurCommon.compareNumbers); + for (let i = 0; i < sortedThat.length; i++) { + for (const error of verifyInteger(sortedThat[i])) { + error.path.prepend( + new IndexSegment( + sortedThat, + i + ) + ); + yield error; + } + } +} + /** * Verify the items of `that` recursively. */ @@ -637,7 +692,27 @@ class Verifier ) } + for (const error of verifyInteger(that.number)) { + error.path.prepend( + new PropertySegment( + that, + "number" + ) + ); + yield error; + } + if (context === true) { + for (const error of verify_ListOf_int(that.numbers)) { + error.path.prepend( + new PropertySegment( + that, + "numbers" + ) + ); + yield error; + } + for (const error of verify_ListOf_Code(that.codes)) { error.path.prepend( new PropertySegment( @@ -705,6 +780,16 @@ class Verifier } if (context === true) { + for (const error of verify_SetOf_int(that.numbers)) { + error.path.prepend( + new PropertySegment( + that, + "numbers" + ) + ); + yield error; + } + for (const error of verify_SetOf_Code(that.codes)) { error.path.prepend( new PropertySegment( diff --git a/dev/test_data/main/typescript/expected/sets/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/sets/expected_output/src/xmlization.ts index 4958c94df..658176c1a 100644 --- a/dev/test_data/main/typescript/expected/sets/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/sets/expected_output/src/xmlization.ts @@ -133,17 +133,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -1329,15 +1325,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/sets/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/sets/expected_output/test/jsonization.concreteClasses.spec.ts index 6afb537e4..16f5b3c1f 100644 --- a/dev/test_data/main/typescript/expected/sets/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/sets/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -445,9 +446,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .number", + "Something refuses an integer outside the safe integer range " + + "at .number in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -456,27 +486,52 @@ test( instance.number = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.number: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .number in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); + + instance.number = value; - expect(caught.path).toStrictEqual(".number"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .numbers[1]", + "Something refuses an integer outside the safe integer range " + + "at .numbers[1] in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -485,20 +540,45 @@ test( instance.numbers = [0, value] as typeof instance.numbers; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.numbers[1]: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .numbers[1] in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".numbers[1]"); + instance.numbers = [0, value] as typeof instance.numbers; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/string_operations/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/string_operations/expected_output/src/jsonization.ts index dbe06527d..c479befe3 100644 --- a/dev/test_data/main/typescript/expected/string_operations/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/string_operations/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/string_operations/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/string_operations/expected_output/src/xmlization.ts index 39cd5f089..4b79a47f5 100644 --- a/dev/test_data/main/typescript/expected/string_operations/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/string_operations/expected_output/src/xmlization.ts @@ -83,38 +83,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -565,25 +533,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/common.ts index 11a40830a..208c38cc4 100644 --- a/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/common.ts @@ -503,5 +503,36 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/jsonization.ts index 039279a2c..da4e60aa5 100644 --- a/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -780,21 +793,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/verification.ts index 3a1870994..4f79efeb1 100644 --- a/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/verification.ts @@ -405,6 +405,22 @@ export function switchWithReassignedInt( return result < 1000; } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -497,6 +513,16 @@ class Verifier ); yield error; } + + for (const error of verifyInteger(that.number)) { + error.path.prepend( + new PropertySegment( + that, + "number" + ) + ); + yield error; + } } } diff --git a/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/xmlization.ts index a1762279a..edd06918e 100644 --- a/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/switch_statements/expected_output/src/xmlization.ts @@ -129,17 +129,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -593,15 +589,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/switch_statements/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/switch_statements/expected_output/test/jsonization.concreteClasses.spec.ts index 71bb7107b..02d5d1595 100644 --- a/dev/test_data/main/typescript/expected/switch_statements/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/switch_statements/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -302,9 +303,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .number", + "Something refuses an integer outside the safe integer range " + + "at .number in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -313,20 +343,45 @@ test( instance.number = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.number: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .number in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".number"); + instance.number = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/common.ts index beb459d10..229d1ba35 100644 --- a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/common.ts @@ -609,5 +609,36 @@ export function floorMod(dividend: number, divisor: number): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/jsonization.ts index 981c61df2..39d2fdb1b 100644 --- a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -995,21 +1008,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/verification.ts index 26459fe63..a8a493a7d 100644 --- a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/verification.ts @@ -206,6 +206,22 @@ export function resetAndCheck( ); } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify an instance of the model recursively or non-recursively (depending on the context). */ @@ -241,6 +257,16 @@ class Verifier "Parity is either zero or one." ) } + + for (const error of verifyInteger(that.count)) { + error.path.prepend( + new PropertySegment( + that, + "count" + ) + ); + yield error; + } } *transformSecondWithContext( @@ -277,6 +303,16 @@ class Verifier "Prefix is at most three long." ) } + + for (const error of verifyInteger(that.count)) { + error.path.prepend( + new PropertySegment( + that, + "count" + ) + ); + yield error; + } } } diff --git a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/xmlization.ts index 602c4b1a4..395fc477c 100644 --- a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/src/xmlization.ts @@ -207,17 +207,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -933,15 +929,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/test/jsonization.concreteClasses.spec.ts index 401eeff5e..f1f50c778 100644 --- a/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/transpiled_methods/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -445,9 +446,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "First serialization fails on an integer outside the range representable in JSON " - + "at .count", + "First refuses an integer outside the safe integer range " + + "at .count in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.firstFromJsonable( @@ -456,27 +486,52 @@ test( instance.count = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.count: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "First accepts the bounds of the safe integer range " + + "at .count in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.firstFromJsonable( + loadTheFirstExpected("First") + ).mustValue(); + + instance.count = value; - expect(caught.path).toStrictEqual(".count"); + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Second serialization fails on an integer outside the range representable in JSON " - + "at .count", + "Second refuses an integer outside the safe integer range " + + "at .count in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.secondFromJsonable( @@ -485,20 +540,45 @@ test( instance.count = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.count: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Second accepts the bounds of the safe integer range " + + "at .count in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.secondFromJsonable( + loadTheFirstExpected("Second") + ).mustValue(); - expect(caught.path).toStrictEqual(".count"); + instance.count = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/tuples/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/tuples/expected_output/src/common.ts index 1ad845117..4e8a7101c 100644 --- a/dev/test_data/main/typescript/expected/tuples/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/tuples/expected_output/src/common.ts @@ -444,5 +444,36 @@ export function base64UrlDecode(text: string): Either { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/tuples/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/tuples/expected_output/src/jsonization.ts index 37238041e..7fdc5f4d9 100644 --- a/dev/test_data/main/typescript/expected/tuples/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/tuples/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -1338,21 +1351,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/tuples/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/tuples/expected_output/src/verification.ts index d3ed72a8c..0f66acc67 100644 --- a/dev/test_data/main/typescript/expected/tuples/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/tuples/expected_output/src/verification.ts @@ -213,6 +213,39 @@ export function narrowedPairIsPositive( return pair[1] > 0; } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + +/** + * Verify the items of `that` recursively. + */ +function *verify_TupleOf2_str_int( + that: readonly [string, number] +): IterableIterator { + for (const error of verifyInteger(that[1])) { + error.path.prepend( + new IndexSegment( + that, + 1 + ) + ); + yield error; + } +} + /** * Verify the items of `that` recursively. */ @@ -253,6 +286,16 @@ function *verify_TupleOf6_int_class_class_class_PositiveInt_Result( OurTypes.Result ] ): IterableIterator { + for (const error of verifyInteger(that[0])) { + error.path.prepend( + new IndexSegment( + that, + 0 + ) + ); + yield error; + } + for (const error of verify(that[1])) { error.path.prepend( new IndexSegment( @@ -328,12 +371,19 @@ class Verifier } *transformAnotherItemWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.AnotherItem, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for AnotherItem. + for (const error of verifyInteger(that.serialNumber)) { + error.path.prepend( + new PropertySegment( + that, + "serialNumber" + ) + ); + yield error; + } } *transformSomethingWithContext( @@ -362,6 +412,16 @@ class Verifier } if (context === true) { + for (const error of verify_TupleOf2_str_int(that.pair)) { + error.path.prepend( + new PropertySegment( + that, + "pair" + ) + ); + yield error; + } + for (const error of verify_TupleOf2_class_class(that.items)) { error.path.prepend( new PropertySegment( @@ -424,6 +484,11 @@ export function *verify( export function *verifyPositiveInt( that: number ): IterableIterator { + if (!Number.isSafeInteger(that)) { + yield* verifyInteger(that); + return; + } + if (!(that > 0)) { yield new VerificationError( "Larger than zero" diff --git a/dev/test_data/main/typescript/expected/tuples/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/tuples/expected_output/src/xmlization.ts index 58496afeb..c1f2e5e38 100644 --- a/dev/test_data/main/typescript/expected/tuples/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/tuples/expected_output/src/xmlization.ts @@ -208,17 +208,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -1414,15 +1410,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/tuples/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/tuples/expected_output/test/jsonization.concreteClasses.spec.ts index 0e614d79a..204392631 100644 --- a/dev/test_data/main/typescript/expected/tuples/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/tuples/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -588,9 +589,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Another_item serialization fails on an integer outside the range representable in JSON " - + "at .serialNumber", + "Another_item refuses an integer outside the safe integer range " + + "at .serialNumber in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.anotherItemFromJsonable( @@ -599,27 +629,52 @@ test( instance.serialNumber = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.serialNumber: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Another_item accepts the bounds of the safe integer range " + + "at .serialNumber in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.anotherItemFromJsonable( + loadTheFirstExpected("AnotherItem") + ).mustValue(); - expect(caught.path).toStrictEqual(".serialNumber"); + instance.serialNumber = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .pair[1]", + "Something refuses an integer outside the safe integer range " + + "at .pair[1] in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -628,27 +683,52 @@ test( instance.pair = [instance.pair[0], value] as typeof instance.pair; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.pair[1]: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .pair[1] in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".pair[1]"); + instance.pair = [instance.pair[0], value] as typeof instance.pair; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); test( - "Something serialization fails on an integer outside the range representable in JSON " - + "at .tricky[0]", + "Something refuses an integer outside the safe integer range " + + "at .tricky[0] in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.somethingFromJsonable( @@ -657,20 +737,45 @@ test( instance.tricky = [value, instance.tricky[1], instance.tricky[2], instance.tricky[3], instance.tricky[4], instance.tricky[5]] as typeof instance.tricky; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.tricky[0]: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Something accepts the bounds of the safe integer range " + + "at .tricky[0] in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.somethingFromJsonable( + loadTheFirstExpected("Something") + ).mustValue(); - expect(caught.path).toStrictEqual(".tricky[0]"); + instance.tricky = [value, instance.tricky[1], instance.tricky[2], instance.tricky[3], instance.tricky[4], instance.tricky[5]] as typeof instance.tricky; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/unions/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/unions/expected_output/src/jsonization.ts index aa9bc901c..50b524507 100644 --- a/dev/test_data/main/typescript/expected/unions/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/unions/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/unions/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/unions/expected_output/src/xmlization.ts index 26827aa5b..bb76f380f 100644 --- a/dev/test_data/main/typescript/expected/unions/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/unions/expected_output/src/xmlization.ts @@ -162,38 +162,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -2177,25 +2145,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/common.ts b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/common.ts index 11a40830a..208c38cc4 100644 --- a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/common.ts +++ b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/common.ts @@ -503,5 +503,36 @@ export function lenStr(text: string): number { } +/** + * 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 { + return `Expected an integer, but got ${value}, which is not a whole number`; +} + + +/** + * 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 { + return ( + `Expected an integer, but got ${value}, which is outside ` + + "the safe integer range [-2^53 + 1, 2^53 - 1]" + ); +} + + // This code has been automatically generated by aas-core-codegen. // Do NOT edit or append. diff --git a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/jsonization.ts index 581408f5d..e71b02163 100644 --- a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/jsonization.ts @@ -430,18 +430,31 @@ function integerFromJsonable( ): OurCommon.Either { if (jsonable === null) { return newDeserializationError( - "Expected an integer number, but got null" + "Expected an integer, but got: null" ); } if (typeof jsonable !== "number") { return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` + `Expected an integer, but got: ${typeof jsonable}` ); } + // NOTE (mristin): + // ``Number.isInteger`` refuses an infinity and a not-a-number as well, and + // accepts a whole-valued literal such as ``1e2``. if (!Number.isInteger(jsonable)) { return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` + OurCommon.notWholeIntegerMessage(jsonable) + ); + } + + // NOTE (mristin): + // ``JSON.parse`` rounds a big integer literal to the nearest double, but + // the rounded value is still outside the safe integer range, so the check + // catches it. + if (!Number.isSafeInteger(jsonable)) { + return newDeserializationError( + OurCommon.unsafeIntegerMessage(jsonable) ); } @@ -1172,21 +1185,22 @@ export class SerializationError extends Error { /** * 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 { - if (!Number.isFinite(that) || that < -9007199254740991 || that > 9007199254740991) { - throw new SerializationError( - `The integer can not be serialized to JSON as it is outside ` + - `the range [-2^53 + 1, 2^53 - 1]: ${that}` - ); + if (!Number.isInteger(that)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(that)); + } + if (!Number.isSafeInteger(that)) { + throw new SerializationError(OurCommon.unsafeIntegerMessage(that)); } return that; } diff --git a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/verification.ts b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/verification.ts index 307ac6527..9b24af631 100644 --- a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/verification.ts +++ b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/verification.ts @@ -545,6 +545,22 @@ export function levelsArePositive( ); } +/** + * 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( + that: number +): IterableIterator { + if (!Number.isInteger(that)) { + yield new VerificationError(OurCommon.notWholeIntegerMessage(that)); + } else if (!Number.isSafeInteger(that)) { + yield new VerificationError(OurCommon.unsafeIntegerMessage(that)); + } +} + /** * Verify the items of `that` recursively. */ @@ -581,12 +597,19 @@ class Verifier } *transformSpecialItemWithContext( - // eslint-disable-next-line @typescript-eslint/no-unused-vars that: OurTypes.SpecialItem, // eslint-disable-next-line @typescript-eslint/no-unused-vars context: boolean ): IterableIterator { - // No verification has been defined for SpecialItem. + for (const error of verifyInteger(that.level)) { + error.path.prepend( + new PropertySegment( + that, + "level" + ) + ); + yield error; + } } *transformContainerWithContext( diff --git a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/xmlization.ts index e661b15aa..d42c07a82 100644 --- a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/src/xmlization.ts @@ -176,17 +176,13 @@ function parse_int( // NOTE (mristin): // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. + // the safe integers exactly. Beyond them ``Number`` rounds silently -- + // 9007199254740993 comes back as 9007199254740992, and 9223372036854775807 + // as 9223372036854776000, which is a perfectly well-formed but *different* + // xs:long. We refuse instead of corrupting, and report the original text. if (!Number.isSafeInteger(value)) { return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` + OurCommon.unsafeIntegerMessage(text) ); } @@ -1145,15 +1141,14 @@ function write_int( value: number ): void { // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. + // Beyond the safe integers a ``number`` no longer holds every integer, so + // the value we were handed has already lost its identity -- writing it out + // would record a different xs:long without a word. + if (!Number.isInteger(value)) { + throw new SerializationError(OurCommon.notWholeIntegerMessage(value)); + } if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); + throw new SerializationError(OurCommon.unsafeIntegerMessage(value)); } parts.push(`${value}`); diff --git a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/test/jsonization.concreteClasses.spec.ts b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/test/jsonization.concreteClasses.spec.ts index c3c9159fa..a5b6835c8 100644 --- a/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/test/jsonization.concreteClasses.spec.ts +++ b/dev/test_data/main/typescript/expected/variance_of_containers/expected_output/test/jsonization.concreteClasses.spec.ts @@ -11,6 +11,7 @@ import * as path from "path"; import * as OurJsonization from "../src/jsonization"; import * as OurTypes from "../src/types"; import * as OurVerification from "../src/verification"; +import * as OurXmlization from "../src/xmlization"; import * as TestCommon from "./common"; @@ -731,9 +732,38 @@ function loadTheFirstExpected(modelType: string): OurJsonization.JsonValue { return TestCommon.readJsonFromFileSync(pths[0]); } +/** + * Verify `instance` and render the errors as `path: message`. + */ +function verificationErrorsOf(instance: OurTypes.Class): Array { + return Array.from(OurVerification.verify(instance)).map( + (error) => `${error.path}: ${error.message}` + ); +} + +/** + * 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 { + try { + serialize(); + } catch (error) { + if ( + error instanceof OurJsonization.SerializationError + || error instanceof OurXmlization.SerializationError + ) { + return `${error.path}: ${error.message}`; + } + throw error; + } + return null; +} + test( - "Special_item serialization fails on an integer outside the range representable in JSON " - + "at .level", + "Special_item refuses an integer outside the safe integer range " + + "at .level in verification and serialization", () => { for (const value of [9007199254740992, -9007199254740992]) { const instance = OurJsonization.specialItemFromJsonable( @@ -742,20 +772,45 @@ test( instance.level = value; - let caught: unknown = null; - try { - OurJsonization.toJsonable(instance); - } catch (error) { - caught = error; - } + const expected = + `.level: Expected an integer, but got ${value}, ` + + "which is outside the safe integer range [-2^53 + 1, 2^53 - 1]"; + + expect(verificationErrorsOf(instance)).toContain(expected); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toStrictEqual(expected); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toStrictEqual(expected); + } + } +); - if (!(caught instanceof OurJsonization.SerializationError)) { - throw new Error( - `Expected a SerializationError, but got: ${caught}` - ); - } +test( + "Special_item accepts the bounds of the safe integer range " + + "at .level in verification and serialization", + () => { + for (const value of [9007199254740991, -9007199254740991]) { + const instance = OurJsonization.specialItemFromJsonable( + loadTheFirstExpected("SpecialItem") + ).mustValue(); - expect(caught.path).toStrictEqual(".level"); + instance.level = value; + + // NOTE (mristin): + // The invariants might still refuse the value, but not the range check. + expect( + verificationErrorsOf(instance).filter( + (error) => error.includes("safe integer range") + ) + ).toStrictEqual([]); + expect( + serializationErrorOf(() => OurJsonization.toJsonable(instance)) + ).toBeNull(); + expect( + serializationErrorOf(() => OurXmlization.toXmlString(instance)) + ).toBeNull(); } } ); diff --git a/dev/test_data/main/typescript/expected/visibility/expected_output/src/jsonization.ts b/dev/test_data/main/typescript/expected/visibility/expected_output/src/jsonization.ts index c2d22cd7d..05356d562 100644 --- a/dev/test_data/main/typescript/expected/visibility/expected_output/src/jsonization.ts +++ b/dev/test_data/main/typescript/expected/visibility/expected_output/src/jsonization.ts @@ -418,36 +418,6 @@ function booleanFromJsonable( return new OurCommon.Either(jsonable, null); } -/** - * Parse `jsonable` as an integer. - * - * @param jsonable - to be parsed - * @returns parsed integer value, or an error - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function integerFromJsonable( - jsonable: JsonValue -): OurCommon.Either { - if (jsonable === null) { - return newDeserializationError( - "Expected an integer number, but got null" - ); - } - if (typeof jsonable !== "number") { - return newDeserializationError( - `Expected an integer number, but got: ${typeof jsonable}` - ); - } - - if (!Number.isInteger(jsonable)) { - return newDeserializationError( - `Expected an integer number, but got: ${jsonable}` - ); - } - - return new OurCommon.Either(jsonable, null); -} - /** * Parse `jsonable` as a number. * diff --git a/dev/test_data/main/typescript/expected/visibility/expected_output/src/xmlization.ts b/dev/test_data/main/typescript/expected/visibility/expected_output/src/xmlization.ts index 1e8237cde..5956c7108 100644 --- a/dev/test_data/main/typescript/expected/visibility/expected_output/src/xmlization.ts +++ b/dev/test_data/main/typescript/expected/visibility/expected_output/src/xmlization.ts @@ -158,38 +158,6 @@ function parse_bool( ); } -function parse_int( - cursor: XmlCursor -): OurCommon.Either { - const text = collapseWhitespace(parseTextContent(cursor)); - - if (!/^[+-]?\d+$/.test(text)) { - return newDeserializationError( - `Expected integer text, but got: ${text}` - ); - } - - const value = Number(text); - - // NOTE (mristin): - // An integer is a ``number`` in TypeScript, and a ``number`` holds only - // the integers up to 2^53 - 1 exactly. Beyond that ``Number`` rounds - // silently -- 9007199254740993 comes back as 9007199254740992, and - // 9223372036854775807 as 9223372036854776000, which is a perfectly - // well-formed but *different* xs:long. We refuse instead of corrupting. - // - // ``Number.isSafeInteger`` covers ``Number.isInteger`` as well, so - // a non-integer is refused here too. - if (!Number.isSafeInteger(value)) { - return newDeserializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${text}` - ); - } - - return new OurCommon.Either(value, null); -} - function parse_float( cursor: XmlCursor ): OurCommon.Either { @@ -617,25 +585,6 @@ function write_bool( parts.push(value ? "true" : "false"); } -function write_int( - parts: Array, - value: number -): void { - // NOTE (mristin): - // Beyond 2^53 - 1 a ``number`` no longer holds every integer, so the value - // we were handed has already lost its identity -- writing it out would - // record a different xs:long without a word. ``Number.isSafeInteger`` - // covers ``Number.isInteger`` as well. - if (!Number.isSafeInteger(value)) { - throw new SerializationError( - `Expected an integer within the safe range of a number, ` - + `but got: ${value}` - ); - } - - parts.push(`${value}`); -} - function write_float( parts: Array, value: number diff --git a/dev/test_data/parse/common_meta_models/int_length_interplay/expected_symbol_table.txt b/dev/test_data/parse/common_meta_models/int_length_interplay/expected_symbol_table.txt index e523ad41d..74879127f 100644 --- a/dev/test_data/parse/common_meta_models/int_length_interplay/expected_symbol_table.txt +++ b/dev/test_data/parse/common_meta_models/int_length_interplay/expected_symbol_table.txt @@ -778,6 +778,54 @@ UnverifiedSymbolTable( postconditions=[]), non_mutating=False, body=[ + textwrap.dedent("""\ + If( + branches=[ + IfBranch( + condition=Comparison( + left=Name( + identifier='start', + original_node=...), + op='LT', + right=Constant( + value=0, + original_node=...), + original_node=...), + body=[ + Return( + value=Constant( + value=False, + original_node=...), + original_node=...)], + original_node=...)], + default=None, + original_node=...)"""), + textwrap.dedent("""\ + If( + branches=[ + IfBranch( + condition=Comparison( + left=Name( + identifier='start', + original_node=...), + op='GE', + right=FunctionCall( + name='len', + args=[ + Name( + identifier='numbers', + original_node=...)], + original_node=...), + original_node=...), + body=[ + Return( + value=Constant( + value=True, + original_node=...), + original_node=...)], + original_node=...)], + default=None, + original_node=...)"""), textwrap.dedent("""\ For( generator=ForRange(