From 1475749fb0d2f4c4afacc4ae1e0b5165628dae98 Mon Sep 17 00:00:00 2001 From: Gang Wu Date: Thu, 17 Sep 2026 14:13:36 +0800 Subject: [PATCH] AVRO-4351: [C++] Preserve primitive custom attributes Preserve unknown properties when parsing and serializing primitive schema objects. This keeps extensions such as Iceberg's adjust-to-utc annotation intact across schema JSON round trips. --- lang/c++/impl/Compiler.cc | 7 +++++++ lang/c++/impl/NodeImpl.cc | 9 ++++++++- lang/c++/test/SchemaTests.cc | 25 +++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lang/c++/impl/Compiler.cc b/lang/c++/impl/Compiler.cc index c0b39f9e743..d69fd6548f4 100644 --- a/lang/c++/impl/Compiler.cc +++ b/lang/c++/impl/Compiler.cc @@ -552,6 +552,13 @@ static NodePtr makeNode(const Entity &e, const Object &m, result = makeMapNode(e, m, st, ns); } else { result = makePrimitive(type); + if (result) { + CustomAttributes customAttributes; + getCustomAttributes(m, customAttributes); + if (!customAttributes.attributes().empty()) { + result->addCustomAttributesForField(customAttributes); + } + } } if (result) { diff --git a/lang/c++/impl/NodeImpl.cc b/lang/c++/impl/NodeImpl.cc index e68eb365e2f..01596a50579 100644 --- a/lang/c++/impl/NodeImpl.cc +++ b/lang/c++/impl/NodeImpl.cc @@ -220,8 +220,10 @@ NodeSymbolic::resolve(const Node &reader) const { void NodePrimitive::printJson(std::ostream &os, size_t depth) const { bool hasLogicalType = logicalType().type() != LogicalType::NONE; + bool hasCustomAttributes = customAttributes_.size() != 0; + bool printAsObject = hasLogicalType || hasCustomAttributes; - if (hasLogicalType) { + if (printAsObject) { os << "{\n" << indent(depth) << "\"type\": "; } @@ -232,6 +234,11 @@ void NodePrimitive::printJson(std::ostream &os, size_t depth) const { os << ",\n" << indent(depth); logicalType().printJson(os); + } + for (size_t i = 0; i != customAttributes_.size(); ++i) { + printCustomAttributes(customAttributes_.get(i), depth, os); + } + if (printAsObject) { os << "\n}"; } if (!getDoc().empty()) { diff --git a/lang/c++/test/SchemaTests.cc b/lang/c++/test/SchemaTests.cc index 2aa39d4146e..13fe0121d27 100644 --- a/lang/c++/test/SchemaTests.cc +++ b/lang/c++/test/SchemaTests.cc @@ -890,6 +890,30 @@ static void testCustomAttributesJson2Schema2Json() { BOOST_CHECK_EQUAL(removeWhitespaceFromSchema(json), removeWhitespaceFromSchema(schema)); } +static void testPrimitiveCustomAttributesJsonRoundTrip() { + const std::vector schemas = { + R"({ + "type": "long", + "logicalType": "timestamp-micros", + "adjust-to-utc": true + })", + R"({ + "type": "long", + "custom-property": "value" + })", + }; + + // Iceberg relies on adjust-to-utc to distinguish timestamp from timestamptz. + // Primitive schema properties must also round-trip without a logical type. + for (const auto &schema : schemas) { + ValidSchema compiledSchema = compileJsonSchemaFromString(schema); + BOOST_REQUIRE_EQUAL(compiledSchema.root()->customAttributes(), 1); + + std::string json = compiledSchema.toJson(); + BOOST_CHECK_EQUAL(removeWhitespaceFromSchema(json), removeWhitespaceFromSchema(schema)); + } +} + static void testCustomAttributesSchema2Json2Schema() { const std::string expected = R"({ "type": "record", @@ -951,6 +975,7 @@ init_unit_test_suite(int /*argc*/, char * /*argv*/[]) { ts->add(BOOST_TEST_CASE(&avro::schema::testParseCustomAttributes)); ts->add(BOOST_TEST_CASE(&avro::schema::testAddCustomAttributes)); ts->add(BOOST_TEST_CASE(&avro::schema::testCustomAttributesJson2Schema2Json)); + ts->add(BOOST_TEST_CASE(&avro::schema::testPrimitiveCustomAttributesJsonRoundTrip)); ts->add(BOOST_TEST_CASE(&avro::schema::testCustomAttributesSchema2Json2Schema)); return ts; }