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(