This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 9b5742cc961e refactor(schema): dissolve AvroSchemaUtils, dedupe compat
(#19810)
9b5742cc961e is described below
commit 9b5742cc961e8756261e82254cbb0dd5fc0605b4
Author: voonhous <[email protected]>
AuthorDate: Fri Sep 4 19:37:49 2026 +0800
refactor(schema): dissolve AvroSchemaUtils, dedupe compat (#19810)
Part 2 of #16639. Part 1 (#19809, 93f1f711e065) wrote down where
schema helpers belong; this removes the class that has no place in
that rule and the logic the compatibility cluster had implemented
twice. Behavior-preserving, internal API only.
AvroSchemaUtils is gone. Its three remaining uses were all inside
HoodieAvroUtils, so the helpers move there verbatim:
getNonNullTypeFromUnion stays public, isNullable becomes private and
createNewSchemaFromFieldsWithReference package-private.
HoodieSchema.Blob builds its fields with a local nullable() helper and
derives REFERENCE_FIELD_COUNT from a REFERENCE_SCHEMA constant instead
of unwrapping the union around it. The caller-less
HoodieSchemaUtils.createNullableSchema shim goes with it.
HoodieSchemaUtils.asNullable runs the InternalSchema nullability
update on the HoodieSchema directly. The old path was HoodieSchema ->
Avro -> HoodieSchema -> InternalSchema -> HoodieSchema -> Avro ->
HoodieSchema, and the outputs are identical. Two details are kept:
Avro's Schema.isNullable is true for a bare NULL type where
HoodieSchema.isNullable is not, so NULL-typed fields are excluded
explicitly, and the all-nullable case returns the input instance. A
non-RECORD argument is now rejected with IllegalArgumentException
rather than an Avro "Not a record" error.
Compatibility cluster:
- lookupWriterField was implemented twice; the facade keeps its
stricter RECORD precondition and delegates to the checker.
- The checker's LONG/FLOAT/DOUBLE/BYTES/STRING cases collapse into one
HoodieSchemaTypePromotion.canPromote call, against the same table
the projection checker uses.
- TIMESTAMP over LONG and UUID over STRING stay checker-only.
isCompatibleProjectionOf(source, target) tests canPromote(target,
source), so adding them would make a timestamp a compatible
projection of a bare long, and writer-schema deduction would then
keep the table's long as the writer schema and silently drop the
logical type.
- Decimal widening is documented, not unified: the projection checker
requires fixed-size parity, the compatibility checker does not.
- The 3-arg isSchemaCompatible named its parameters reader/writer
while routing them prev/new, so they are renamed, not reordered.
Tests pin what the refactor could have moved: the persisted Blob
schema JSON as a literal, the promotion table from both sides
including the five reverse numeric narrowings and canPromote(LONG,
DATE/TIME), lookupWriterField (direct, alias, ambiguous, absent,
non-record), the argument order of areSchemasCompatible, and the three
pre-existing losses of the asNullable round trip (non-null default,
ENUM, null-last union) with a VECTOR column surviving it.
TestAvroSchemaUtils folds into TestHoodieAvroUtils.
TestHoodieTableSchemaEvolution.testFieldWithAlias gains a type change
so the alias match, not the writerField != null guard, decides its
outcome.
Two javadocs said the surviving toAvroSchema/fromAvroSchema
delegations were being retired under #16639. Where a helper belongs is
that issue; whether its body round-trips is #14263, which allows
conversions only at the memory-to-disk, disk-to-memory and engine
boundaries. They now point at #14263.
Six review items raised on part 1 but not applied before it merged
come along: a bloom-filter fixture spelled out three times in
TestHoodieMetadataPayload, a note on the assertSame there that is
implied by the instance check above it, the missing explanation for why
an equal-but-distinct class schema throws
ArrayIndexOutOfBoundsException, a note on why
collectColumnRangeFieldValueV1's AVRO branch calls the static helper
rather than dispatching (HoodieAvroIndexedRecord reports type AVRO and
overrides getColumnValues), two "@return Column value." on methods
returning an Object[], and javadoc for createSchemaErrorString.
Found on the way and filed rather than fixed here: asNullable on a
table with a BLOB column returns the wrong field, because
InternalSchemaConverter gives the blob's nested reference fields ids
0..3 into a flat id map (#19833).
Closes #16639
---
.../hudi/table/TestHoodieTableSchemaEvolution.java | 11 ++
.../apache/hudi/common/avro/AvroSchemaUtils.java | 146 -----------------
.../apache/hudi/common/avro/HoodieAvroUtils.java | 88 +++++++++--
.../apache/hudi/common/schema/HoodieSchema.java | 28 +++-
.../common/schema/HoodieSchemaCompatibility.java | 48 +++---
.../schema/HoodieSchemaCompatibilityChecker.java | 22 +--
.../common/schema/HoodieSchemaTypePromotion.java | 13 +-
.../hudi/common/schema/HoodieSchemaUtils.java | 91 +++++++----
.../hudi/metadata/HoodieTableMetadataUtil.java | 3 +
.../hudi/common/avro/TestAvroSchemaUtils.java | 70 ---------
.../hudi/common/avro/TestHoodieAvroUtils.java | 74 ++++++++-
.../hudi/common/schema/TestHoodieSchema.java | 21 ++-
.../schema/TestHoodieSchemaCompatibility.java | 153 ++++++++++++++++++
.../schema/TestHoodieSchemaTypePromotion.java | 9 ++
.../hudi/common/schema/TestHoodieSchemaUtils.java | 174 +++++++++++++++++++++
.../avro/TestHoodieAvroWriteSupportShredding.java | 2 +-
.../hudi/metadata/TestHoodieMetadataPayload.java | 23 +--
17 files changed, 658 insertions(+), 318 deletions(-)
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/TestHoodieTableSchemaEvolution.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/TestHoodieTableSchemaEvolution.java
index 793a21d741ec..4dd44817b27f 100644
---
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/TestHoodieTableSchemaEvolution.java
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/TestHoodieTableSchemaEvolution.java
@@ -189,6 +189,17 @@ public class TestHoodieTableSchemaEvolution {
// Should pass because the field is found via alias and type hasn't changed
assertDoesNotThrow(() ->
HoodieTable.validateSecondaryIndexSchemaEvolution(tableSchema,
writerSchema, indexMetadata));
+
+ // The assertion above passes whether or not the alias resolves: an
unresolved alias returns a null writer
+ // field, which the writerField != null guard then skips. Changing the
type behind the same alias is what
+ // makes the lookup decide the outcome.
+ String retypedWriterSchemaStr = writerSchemaStr.replace(
+ "{\"name\": \"new_name\", \"type\": \"string\"}",
+ "{\"name\": \"new_name\", \"type\": \"int\"}");
+ HoodieSchema retypedWriterSchema =
HoodieSchema.parse(retypedWriterSchemaStr);
+
+ assertThrows(SchemaCompatibilityException.class, () ->
+ HoodieTable.validateSecondaryIndexSchemaEvolution(tableSchema,
retypedWriterSchema, indexMetadata));
}
@Test
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaUtils.java
b/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaUtils.java
deleted file mode 100644
index 1b12c3840456..000000000000
--- a/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaUtils.java
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.hudi.common.avro;
-
-import org.apache.hudi.common.schema.HoodieSchema;
-import org.apache.hudi.common.schema.internal.InternalSchema;
-import org.apache.hudi.common.schema.internal.action.TableChanges;
-import org.apache.hudi.common.schema.internal.utils.SchemaChangeUtils;
-import org.apache.hudi.exception.HoodieAvroSchemaException;
-
-import lombok.AccessLevel;
-import lombok.NoArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.avro.Schema;
-
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-import static
org.apache.hudi.common.schema.internal.convert.InternalSchemaConverter.convert;
-import static org.apache.hudi.common.util.CollectionUtils.reduce;
-import static org.apache.hudi.common.util.ValidationUtils.checkState;
-
-/**
- * Avro-typed schema helpers, retained only as the delegate target of the call
sites that have not moved to
- * HoodieSchema yet: {@link
org.apache.hudi.common.schema.HoodieSchemaUtils#asNullable(HoodieSchema)} and
- * {@code HoodieSchemaUtils#createNullableSchema}, the field construction
inside {@link HoodieSchema.Blob},
- * and a handful of internal uses in {@link HoodieAvroUtils}.
- *
- * <p>This class is being retired under #16639. Do not add methods here: every
method on this class except
- * {@link #getNonNullTypeFromUnion(Schema)} already has a HoodieSchema twin,
so use
- * {@link org.apache.hudi.common.schema.HoodieSchema} or {@link
org.apache.hudi.common.schema.HoodieSchemaUtils} instead.</p>
- */
-@NoArgsConstructor(access = AccessLevel.PRIVATE)
-@Slf4j
-public class AvroSchemaUtils {
-
- /**
- * Create a new schema but maintain all meta info from the old schema
- *
- * @param schema schema to get the meta info from
- * @param fields list of fields in order that will be in the new schema
- *
- * @return schema with fields from fields, and metadata from schema
- */
- public static Schema createNewSchemaFromFieldsWithReference(Schema schema,
List<Schema.Field> fields) {
- if (schema == null) {
- throw new IllegalArgumentException("Schema must not be null");
- }
- Schema newSchema = Schema.createRecord(schema.getName(), schema.getDoc(),
schema.getNamespace(), schema.isError());
- Map<String, Object> schemaProps = Collections.emptyMap();
- try {
- schemaProps = schema.getObjectProps();
- } catch (Exception e) {
- log.warn("Error while getting object properties from schema: {}",
schema, e);
- }
- for (Map.Entry<String, Object> prop : schemaProps.entrySet()) {
- newSchema.addProp(prop.getKey(), prop.getValue());
- }
- newSchema.setFields(fields);
- return newSchema;
- }
-
- /**
- * Returns true in case provided {@link Schema} is nullable (ie accepting
null values),
- * returns false otherwise
- */
- public static boolean isNullable(Schema schema) {
- if (schema.getType() != Schema.Type.UNION) {
- return false;
- }
-
- List<Schema> innerTypes = schema.getTypes();
- return innerTypes.size() > 1 && innerTypes.stream().anyMatch(it ->
it.getType() == Schema.Type.NULL);
- }
-
- /**
- * Resolves typical Avro's nullable schema definition: {@code
Union(Schema.Type.NULL, <NonNullType>)},
- * decomposing union and returning the target non-null type
- * <p>
- * This is the strict variant: it throws unless the union has exactly one
null branch and one non-null
- * branch. See the union-unwrapping note on {@link HoodieAvroUtils} for the
lenient alternatives.
- * </p>
- */
- public static Schema getNonNullTypeFromUnion(Schema schema) {
- if (schema.getType() != Schema.Type.UNION) {
- return schema;
- }
-
- List<Schema> innerTypes = schema.getTypes();
-
- if (innerTypes.size() != 2) {
- throw new HoodieAvroSchemaException(
- String.format("Unsupported Avro UNION type %s: Only UNION of a null
type and a non-null type is supported", schema));
- }
- Schema firstInnerType = innerTypes.get(0);
- Schema secondInnerType = innerTypes.get(1);
- if ((firstInnerType.getType() != Schema.Type.NULL &&
secondInnerType.getType() != Schema.Type.NULL)
- || (firstInnerType.getType() == Schema.Type.NULL &&
secondInnerType.getType() == Schema.Type.NULL)) {
- throw new HoodieAvroSchemaException(
- String.format("Unsupported Avro UNION type %s: Only UNION of a null
type and a non-null type is supported", schema));
- }
- return firstInnerType.getType() == Schema.Type.NULL ? secondInnerType :
firstInnerType;
- }
-
- public static Schema createNullableSchema(Schema schema) {
- checkState(schema.getType() != Schema.Type.NULL);
- return Schema.createUnion(Schema.create(Schema.Type.NULL), schema);
- }
-
- /**
- * Create a new schema by force changing all the fields as nullable.
- *
- * @param schema original schema
- * @return a new schema with all the fields updated as nullable.
- */
- public static Schema asNullable(Schema schema) {
- List<String> filterCols = schema.getFields().stream()
- .filter(f ->
!f.schema().isNullable()).map(Schema.Field::name).collect(Collectors.toList());
- if (filterCols.isEmpty()) {
- return schema;
- }
- InternalSchema internalSchema =
convert(HoodieSchema.fromAvroSchema(schema));
- TableChanges.ColumnUpdateChange schemaChange =
TableChanges.ColumnUpdateChange.get(internalSchema);
- schemaChange = reduce(filterCols, schemaChange,
- (change, field) -> change.updateColumnNullability(field, true));
- return convert(SchemaChangeUtils.applyTableChanges2Schema(internalSchema,
schemaChange), schema.getFullName()).toAvroSchema();
- }
-}
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java
b/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java
index 1a83ba67fa63..cc98489cce52 100644
--- a/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java
+++ b/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java
@@ -145,7 +145,7 @@ import static
org.apache.hudi.common.util.ValidationUtils.checkState;
* <ul>
* <li>{@code unwrapNullable} (this class) - lenient: the first non-null
branch of any union</li>
* <li>{@code getActualSchemaFromUnion} (this class, private) - resolves
complex unions against the datum</li>
- * <li>{@code AvroSchemaUtils#getNonNullTypeFromUnion} - strict: throws
unless the union is exactly one null
+ * <li>{@code getNonNullTypeFromUnion} (this class) - strict: throws unless
the union is exactly one null
* branch and one non-null branch</li>
* <li>{@link HoodieSchema#getNonNullType()} - strips null branches and
never throws</li>
* <li>{@code HoodieSchemaUtils#resolveUnionSchema} - selects a branch by
full name</li>
@@ -387,6 +387,76 @@ public class HoodieAvroUtils {
return new Schema.Field(name, schema, doc,
convertDefaultValueForAvroCompatibility(defaultValue), order);
}
+ /**
+ * Resolves typical Avro's nullable schema definition: {@code
Union(Schema.Type.NULL, <NonNullType>)},
+ * decomposing union and returning the target non-null type
+ * <p>
+ * This is the strict variant: a non-union schema is returned as is, while a
union must have exactly two
+ * branches of which exactly one is {@link Schema.Type#NULL}, otherwise a
{@link HoodieAvroSchemaException}
+ * is thrown. For the lenient alternatives see {@code unwrapNullable} on
this class (first non-null branch
+ * of any union) and {@link HoodieSchema#getNonNullType()} (strips null
branches and never throws).
+ * </p>
+ */
+ public static Schema getNonNullTypeFromUnion(Schema schema) {
+ if (schema.getType() != Schema.Type.UNION) {
+ return schema;
+ }
+
+ List<Schema> innerTypes = schema.getTypes();
+
+ if (innerTypes.size() != 2) {
+ throw new HoodieAvroSchemaException(
+ String.format("Unsupported Avro UNION type %s: Only UNION of a null
type and a non-null type is supported", schema));
+ }
+ Schema firstInnerType = innerTypes.get(0);
+ Schema secondInnerType = innerTypes.get(1);
+ if ((firstInnerType.getType() != Schema.Type.NULL &&
secondInnerType.getType() != Schema.Type.NULL)
+ || (firstInnerType.getType() == Schema.Type.NULL &&
secondInnerType.getType() == Schema.Type.NULL)) {
+ throw new HoodieAvroSchemaException(
+ String.format("Unsupported Avro UNION type %s: Only UNION of a null
type and a non-null type is supported", schema));
+ }
+ return firstInnerType.getType() == Schema.Type.NULL ? secondInnerType :
firstInnerType;
+ }
+
+ /**
+ * Returns true in case provided {@link Schema} is nullable (ie accepting
null values),
+ * returns false otherwise
+ */
+ private static boolean isNullable(Schema schema) {
+ if (schema.getType() != Schema.Type.UNION) {
+ return false;
+ }
+
+ List<Schema> innerTypes = schema.getTypes();
+ return innerTypes.size() > 1 && innerTypes.stream().anyMatch(it ->
it.getType() == Schema.Type.NULL);
+ }
+
+ /**
+ * Create a new schema but maintain all meta info from the old schema
+ *
+ * @param schema schema to get the meta info from
+ * @param fields list of fields in order that will be in the new schema
+ *
+ * @return schema with fields from fields, and metadata from schema
+ */
+ static Schema createNewSchemaFromFieldsWithReference(Schema schema,
List<Schema.Field> fields) {
+ if (schema == null) {
+ throw new IllegalArgumentException("Schema must not be null");
+ }
+ Schema newSchema = Schema.createRecord(schema.getName(), schema.getDoc(),
schema.getNamespace(), schema.isError());
+ Map<String, Object> schemaProps = Collections.emptyMap();
+ try {
+ schemaProps = schema.getObjectProps();
+ } catch (Exception e) {
+ log.warn("Error while getting object properties from schema: {}",
schema, e);
+ }
+ for (Map.Entry<String, Object> prop : schemaProps.entrySet()) {
+ newSchema.addProp(prop.getKey(), prop.getValue());
+ }
+ newSchema.setFields(fields);
+ return newSchema;
+ }
+
private static Schema removeFields(Schema schema, Set<String>
fieldsToRemove) {
List<Schema.Field> filteredFields = schema.getFields()
.stream()
@@ -394,7 +464,7 @@ public class HoodieAvroUtils {
.map(HoodieAvroUtils::createNewSchemaField)
.collect(Collectors.toList());
- return AvroSchemaUtils.createNewSchemaFromFieldsWithReference(schema,
filteredFields);
+ return createNewSchemaFromFieldsWithReference(schema, filteredFields);
}
@VisibleForTesting
@@ -404,13 +474,13 @@ public class HoodieAvroUtils {
.stream()
.map(field -> {
if (Objects.equals(field.name(), fieldName)) {
- return createNewSchemaField(field.name(),
AvroSchemaUtils.getNonNullTypeFromUnion(field.schema()), field.doc(),
fieldDefaultValue);
+ return createNewSchemaField(field.name(),
getNonNullTypeFromUnion(field.schema()), field.doc(), fieldDefaultValue);
} else {
return createNewSchemaField(field);
}
})
.collect(Collectors.toList());
- return AvroSchemaUtils.createNewSchemaFromFieldsWithReference(schema,
filteredFields);
+ return createNewSchemaFromFieldsWithReference(schema, filteredFields);
}
/**
@@ -757,11 +827,11 @@ public class HoodieAvroUtils {
if (fieldSchema == null) {
return fieldValue;
} else if (fieldValue == null) {
- checkState(AvroSchemaUtils.isNullable(fieldSchema));
+ checkState(isNullable(fieldSchema));
return null;
}
- return
convertValueForAvroLogicalTypes(AvroSchemaUtils.getNonNullTypeFromUnion(fieldSchema),
fieldValue, consistentLogicalTimestampEnabled);
+ return
convertValueForAvroLogicalTypes(getNonNullTypeFromUnion(fieldSchema),
fieldValue, consistentLogicalTimestampEnabled);
}
/**
@@ -924,7 +994,7 @@ public class HoodieAvroUtils {
* @param record Hoodie record.
* @param columns Names of the columns to get values.
* @param schema {@link HoodieSchema} instance.
- * @return Column value.
+ * @return the column values, in the order of {@code columns}.
*/
public static Object[] getRecordColumnValues(HoodieRecord record,
String[] columns,
@@ -950,7 +1020,7 @@ public class HoodieAvroUtils {
* @param record Hoodie record.
* @param columns Names of the columns to get values.
* @param schema {@link HoodieSchema} instance.
- * @return Column value.
+ * @return the column values, in the order of {@code columns}.
*/
public static Object[]
getSortColumnValuesWithPartitionPathAndRecordKey(HoodieRecord record,
String[] columns,
@@ -1070,7 +1140,7 @@ public class HoodieAvroUtils {
newRecord.put(i,
rewriteRecordWithNewSchema(indexedRecord.get(oldField.pos()),
oldField.schema(), newField.schema(), renameCols, fieldNames, false));
} else if (newField.defaultVal() instanceof JsonProperties.Null) {
newRecord.put(i, null);
- } else if (!AvroSchemaUtils.isNullable(newField.schema()) &&
newField.defaultVal() == null) {
+ } else if (!isNullable(newField.schema()) && newField.defaultVal()
== null) {
throw new SchemaCompatibilityException("Field " +
createFullName(fieldNames) + " has no default value and is non-nullable");
} else {
newRecord.put(i, newField.defaultVal());
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java
index 5f6cec1bc483..53fe10e362e9 100644
--- a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java
+++ b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java
@@ -18,7 +18,6 @@
package org.apache.hudi.common.schema;
-import org.apache.hudi.common.avro.AvroSchemaUtils;
import org.apache.hudi.common.schema.internal.HoodieSchemaException;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.StringUtils;
@@ -2887,8 +2886,10 @@ public class HoodieSchema implements Serializable {
public static class Blob extends HoodieSchema {
public static final String TYPE_DESCRIPTOR = "BLOB";
private static final String DEFAULT_NAME = "blob";
+ // declared before BLOB_FIELDS: createBlobFields() reads it while the
class is being initialized
+ private static final Schema REFERENCE_SCHEMA = createReferenceSchema();
private static final List<Schema.Field> BLOB_FIELDS = createBlobFields();
- private static final int REFERENCE_FIELD_COUNT =
AvroSchemaUtils.getNonNullTypeFromUnion(BLOB_FIELDS.get(2).schema()).getFields().size();
+ private static final int REFERENCE_FIELD_COUNT =
REFERENCE_SCHEMA.getFields().size();
public static final String INLINE = "INLINE";
public static final String OUT_OF_LINE = "OUT_OF_LINE";
@@ -2953,23 +2954,34 @@ public class HoodieSchema implements Serializable {
return blobSchema;
}
- private static List<Schema.Field> createBlobFields() {
- Schema bytesField = Schema.create(Schema.Type.BYTES);
+ private static Schema createReferenceSchema() {
Schema referenceField = Schema.createRecord(EXTERNAL_REFERENCE, null,
null, false);
List<Schema.Field> referenceFields = Arrays.asList(
new Schema.Field(EXTERNAL_REFERENCE_PATH,
Schema.create(Schema.Type.STRING), null, null),
- new Schema.Field(EXTERNAL_REFERENCE_OFFSET,
AvroSchemaUtils.createNullableSchema(Schema.create(Schema.Type.LONG)), null,
null),
- new Schema.Field(EXTERNAL_REFERENCE_LENGTH,
AvroSchemaUtils.createNullableSchema(Schema.create(Schema.Type.LONG)), null,
null),
+ new Schema.Field(EXTERNAL_REFERENCE_OFFSET,
nullable(Schema.create(Schema.Type.LONG)), null, null),
+ new Schema.Field(EXTERNAL_REFERENCE_LENGTH,
nullable(Schema.create(Schema.Type.LONG)), null, null),
new Schema.Field(EXTERNAL_REFERENCE_IS_MANAGED,
Schema.create(Schema.Type.BOOLEAN), null, null)
);
referenceField.setFields(referenceFields);
+ return referenceField;
+ }
+ private static List<Schema.Field> createBlobFields() {
+ Schema bytesField = Schema.create(Schema.Type.BYTES);
return Arrays.asList(
new Schema.Field(TYPE, Schema.createEnum("blob_storage_type", null,
null, Arrays.asList(INLINE, OUT_OF_LINE)), null, null),
- new Schema.Field(INLINE_DATA_FIELD,
AvroSchemaUtils.createNullableSchema(bytesField), null,
Schema.Field.NULL_DEFAULT_VALUE),
- new Schema.Field(EXTERNAL_REFERENCE,
AvroSchemaUtils.createNullableSchema(referenceField), null,
Schema.Field.NULL_DEFAULT_VALUE)
+ new Schema.Field(INLINE_DATA_FIELD, nullable(bytesField), null,
Schema.Field.NULL_DEFAULT_VALUE),
+ new Schema.Field(EXTERNAL_REFERENCE, nullable(REFERENCE_SCHEMA),
null, Schema.Field.NULL_DEFAULT_VALUE)
);
}
+
+ /**
+ * Wraps the given schema into the canonical Avro nullable union {@code
[null, schema]}. None of the blob
+ * field types is NULL, so no further validation is needed here.
+ */
+ private static Schema nullable(Schema schema) {
+ return Schema.createUnion(Schema.create(Schema.Type.NULL), schema);
+ }
}
private void writeObject(ObjectOutputStream oos) throws IOException {
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibility.java
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibility.java
index 3c98b3b45ee1..e9a0aea0d9d0 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibility.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibility.java
@@ -51,6 +51,20 @@ import java.util.stream.Collectors;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class HoodieSchemaCompatibility {
+ /**
+ * Establishes whether data written with {@code writerSchema} can be read
with {@code tableSchema}.
+ *
+ * <p>NOTE: the reader is the FIRST argument here, the opposite order from
+ * {@link #isSchemaCompatible(HoodieSchema, HoodieSchema, boolean,
boolean)}, where the reader is the
+ * second argument. Schema fully-qualified names are NOT checked. Unlike
{@code isSchemaCompatible}
+ * with {@code allowProjection=false}, no missing-field (projection)
pre-check is applied: a reader
+ * that simply drops writer fields is reported as compatible.</p>
+ *
+ * @param tableSchema the schema used to read the data
+ * @param writerSchema the schema the data was written with
+ * @return true if tableSchema can read data written with writerSchema
+ * @see #isSchemaCompatible(HoodieSchema, HoodieSchema, boolean, boolean)
+ */
public static boolean areSchemasCompatible(HoodieSchema tableSchema,
HoodieSchema writerSchema) {
return
HoodieSchemaCompatibilityChecker.checkReaderWriterCompatibility(tableSchema,
writerSchema, false).getType() ==
HoodieSchemaCompatibilityChecker.SchemaCompatibilityType.COMPATIBLE;
}
@@ -205,18 +219,21 @@ public final class HoodieSchemaCompatibility {
}
/**
- * Checks if two schemas are compatible with projection support.
- * This allows the reader schema to have fewer fields than the writer schema.
+ * Establishes whether {@code newSchema} is compatible w/ {@code
prevSchema}, checking schemas
+ * fully-qualified names.
+ * From avro's compatibility standpoint, prevSchema is the writer schema and
newSchema is the reader schema.
+ * {@code newSchema} is considered compatible to {@code prevSchema}, iff
data written using {@code prevSchema}
+ * could be read by {@code newSchema}
*
- * @param readerSchema the schema used to read the data
- * @param writerSchema the schema used to write the data
+ * @param prevSchema previous instance of the schema
+ * @param newSchema new instance of the schema
* @param allowProjection whether to allow fewer fields in reader schema
* @return true if reader schema can read data written with writer schema
* @throws IllegalArgumentException if schemas are null
*/
- public static boolean isSchemaCompatible(HoodieSchema readerSchema,
HoodieSchema writerSchema,
+ public static boolean isSchemaCompatible(HoodieSchema prevSchema,
HoodieSchema newSchema,
boolean allowProjection) {
- return isSchemaCompatible(readerSchema, writerSchema, true,
allowProjection);
+ return isSchemaCompatible(prevSchema, newSchema, true, allowProjection);
}
/**
@@ -281,25 +298,12 @@ public final class HoodieSchemaCompatibility {
* @param writerSchema Schema of the record where to look for the writer
field.
* @param readerField Reader field to identify the corresponding writer
field
* of.
- * @return the writer field, if any does correspond, or None.
+ * @return the writer field, if any does correspond, or null.
+ * @throws IllegalArgumentException if {@code writerSchema} is not a record
*/
public static HoodieSchemaField lookupWriterField(final HoodieSchema
writerSchema, final HoodieSchemaField readerField) {
ValidationUtils.checkArgument(writerSchema.getType() ==
HoodieSchemaType.RECORD, writerSchema + " is not a record");
- Option<HoodieSchemaField> directOpt =
writerSchema.getField(readerField.name());
- // Check aliases
- for (final String readerFieldAliasName :
readerField.getAvroField().aliases()) {
- final Option<HoodieSchemaField> writerFieldOpt =
writerSchema.getField(readerFieldAliasName);
- if (writerFieldOpt.isPresent()) {
- if (directOpt.isPresent()) {
- // Multiple matches found, fail fast
- throw new HoodieSchemaException(String.format(
- "Reader record field %s matches multiple fields in writer record
schema %s", readerField, writerSchema));
- }
- directOpt = writerFieldOpt;
- }
- }
-
- return directOpt.orElse(null);
+ return HoodieSchemaCompatibilityChecker.lookupWriterField(writerSchema,
readerField);
}
/**
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java
index f92c043c8b45..28c3dc4eafda 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java
@@ -112,7 +112,7 @@ public class HoodieSchemaCompatibilityChecker {
* @param writerSchema Schema of the record where to look for the writer
field.
* @param readerField Reader field to identify the corresponding writer
field
* of.
- * @return the writer field, if any does correspond, or None.
+ * @return the writer field, if any does correspond, or null.
*/
public static HoodieSchemaField lookupWriterField(final HoodieSchema
writerSchema, final HoodieSchemaField readerField) {
assert (writerSchema.hasFields());
@@ -357,24 +357,26 @@ public class HoodieSchemaCompatibilityChecker {
case DATE:
case DECIMAL:
return result.mergedWith(typeMismatch(reader, writer, locations));
+ // TIMESTAMP over LONG and UUID over STRING are reader/writer
compatibility rules only. They are deliberately
+ // absent from HoodieSchemaTypePromotion:
isCompatibleProjectionOf(source, target) tests
+ // canPromote(target, source), so the entry would make a timestamp a
compatible projection of a bare long,
+ // and writer-schema deduction (HoodieSchemaUtils.scala) would then
keep the table's long as the writer
+ // schema and silently drop the logical type. The deduction that
produces a TIMESTAMP-reader / LONG-writer
+ // pair is gated per field by
hoodie.write.timestamp.logical.type.overrides (#19384); this checker accepts
+ // the pair once produced.
case TIMESTAMP:
return (writer.getType() == HoodieSchemaType.LONG) ? result :
result.mergedWith(typeMismatch(reader, writer, locations));
case UUID:
return (writer.getType() == HoodieSchemaType.STRING) ? result :
result.mergedWith(typeMismatch(reader, writer, locations));
+ // The primitive widening table (LONG <- INT, FLOAT <- INT/LONG,
DOUBLE <- INT/LONG/FLOAT, BYTES <- STRING,
+ // STRING <- BYTES or any numeric) lives in
HoodieSchemaTypePromotion, shared with the projection checker.
case LONG:
- return (writer.getType() == HoodieSchemaType.INT) ? result :
result.mergedWith(typeMismatch(reader, writer, locations));
case FLOAT:
- return ((writer.getType() == HoodieSchemaType.INT) ||
(writer.getType() == HoodieSchemaType.LONG)) ? result
- : result.mergedWith(typeMismatch(reader, writer, locations));
case DOUBLE:
- return ((writer.getType() == HoodieSchemaType.INT) ||
(writer.getType() == HoodieSchemaType.LONG) || (writer.getType() ==
HoodieSchemaType.FLOAT))
- ? result
- : result.mergedWith(typeMismatch(reader, writer, locations));
case BYTES:
- return (writer.getType() == HoodieSchemaType.STRING) ? result :
result.mergedWith(typeMismatch(reader, writer, locations));
case STRING:
- return (writer.getType().isNumeric() || (writer.getType() ==
HoodieSchemaType.BYTES)
- ? result : result.mergedWith(typeMismatch(reader, writer,
locations)));
+ return HoodieSchemaTypePromotion.canPromote(reader.getType(),
writer.getType())
+ ? result : result.mergedWith(typeMismatch(reader, writer,
locations));
case ARRAY:
return result.mergedWith(typeMismatch(reader, writer, locations));
case MAP:
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaTypePromotion.java
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaTypePromotion.java
index 13ae75f194a6..b70e2c4903ae 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaTypePromotion.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaTypePromotion.java
@@ -19,7 +19,8 @@
package org.apache.hudi.common.schema;
/**
- * The single table of primitive widening promotions, used by {@link
HoodieSchemaProjectionChecker}.
+ * The single table of primitive widening promotions, used by {@link
HoodieSchemaProjectionChecker} and, for the
+ * primitive cases, by {@link HoodieSchemaCompatibilityChecker}.
*
* <p>A promotion lets a reader schema with a wider type read data written
with a narrower one:</p>
* <ul>
@@ -33,16 +34,16 @@ package org.apache.hudi.common.schema;
*
* <p>Logical-type-over-primitive promotions are deliberately NOT in this
table. A TIMESTAMP reader over a
* LONG writer, or a UUID reader over a STRING writer, is accepted by
- * {@link HoodieSchemaCompatibilityChecker} for reader/writer compatibility,
but it must not make a bare
- * long a "compatible projection" of a timestamp: writer-schema deduction
would then silently drop the
- * logical type. Compatibility and projection are different questions, so they
use different tables.</p>
+ * {@link HoodieSchemaCompatibilityChecker} for reader/writer compatibility,
but it must not make a
+ * timestamp a "compatible projection" of a bare long -- {@code
isCompatibleProjectionOf(source, target)}
+ * tests {@code canPromote(target, source)}, so the entry would let
writer-schema deduction keep the
+ * table's long as the writer schema and silently drop the logical type.
Compatibility and projection are
+ * different questions, so they use different tables.</p>
*
* <p>One more difference is documented rather than resolved:
* {@link #isDecimalWidening(HoodieSchema, HoodieSchema)} additionally
requires the same backing (fixed
* versus bytes) and, for fixed, an equal fixed size, whereas the decimal
check in
* {@code HoodieSchemaCompatibilityChecker} compares only precision and
scale.</p>
- *
- * <p>This class is package-private and used only by {@link
HoodieSchemaProjectionChecker}.</p>
*/
class HoodieSchemaTypePromotion {
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java
index fea4bc3ae8fa..ac2de5fe79fc 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java
@@ -18,10 +18,14 @@
package org.apache.hudi.common.schema;
-import org.apache.hudi.common.avro.AvroSchemaUtils;
import org.apache.hudi.common.avro.HoodieAvroUtils;
import org.apache.hudi.common.model.HoodieRecord;
import org.apache.hudi.common.schema.internal.HoodieSchemaException;
+import org.apache.hudi.common.schema.internal.InternalSchema;
+import org.apache.hudi.common.schema.internal.action.TableChanges;
+import org.apache.hudi.common.schema.internal.convert.InternalSchemaConverter;
+import org.apache.hudi.common.schema.internal.utils.SchemaChangeUtils;
+import org.apache.hudi.common.util.CollectionUtils;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.ValidationUtils;
import org.apache.hudi.common.util.VisibleForTesting;
@@ -64,6 +68,7 @@ import java.util.stream.Stream;
* {@link #toJavaDefaultValue(HoodieSchemaField)}</li>
* <li>nullability: {@link #asNullable(HoodieSchema)}</li>
* <li>naming: {@link #sanitizeName(String)}, {@link
#getRecordQualifiedName(String)}</li>
+ * <li>error text: {@link #createSchemaErrorString(String, HoodieSchema,
HoodieSchema)}</li>
* <li>lookups and predicates that need more than {@link HoodieSchema}
offers on its own:
* {@link #findNestedField(HoodieSchema, String)}, {@link
#findMissingFields(HoodieSchema, HoodieSchema)},
* {@link #resolveUnionSchema(HoodieSchema, String)}, {@link
#hasDecimalField(HoodieSchema)}</li>
@@ -80,11 +85,12 @@ import java.util.stream.Stream;
* <li>the field-id InternalSchema (schema-on-read) domain: {@code
org.apache.hudi.common.schema.internal}</li>
* </ul>
*
- * <p>A few methods here still delegate to Avro-typed implementations
- * ({@link #asNullable(HoodieSchema)}, {@link
#createNullableSchema(HoodieSchema)},
- * {@link #projectSchema(HoodieSchema, List)} and the 5-arg
- * {@link #createNewSchemaField(String, HoodieSchema, String, Object,
HoodieFieldOrder)}). Those delegations
- * are being retired under #16639; new methods must be implemented on
HoodieSchema directly.</p>
+ * <p>A couple of methods here still delegate to Avro-typed implementations
+ * ({@link #projectSchema(HoodieSchema, List)} and the 5-arg
+ * {@link #createNewSchemaField(String, HoodieSchema, String, Object,
HoodieFieldOrder)}). An internal
+ * toAvroSchema/fromAvroSchema hop is not one of the conversion boundaries
RFC-99 allows (memory to disk,
+ * disk to memory, engine boundary), so those delegations are being retired
under #14263; new methods must
+ * be implemented on HoodieSchema directly. Where a helper belongs, by
contrast, is #16639.</p>
*
* @since 1.2.0
*/
@@ -248,36 +254,46 @@ public final class HoodieSchemaUtils {
}
/**
- * Creates a nullable version of the given schema (union of null and the
schema).
- *
- * <p>{@link HoodieSchema#createNullable(HoodieSchema)} is the idempotent
native equivalent and is
- * preferred; this overload round-trips through Avro and is retained only
for existing call sites.</p>
- *
- * @param schema the input schema
- * @return new HoodieSchema that allows null values
- * @throws IllegalArgumentException if schema is null
- */
- public static HoodieSchema createNullableSchema(HoodieSchema schema) {
- ValidationUtils.checkArgument(schema != null, "Schema cannot be null");
-
- // Delegate to AvroSchemaUtils
- Schema nullableAvro =
AvroSchemaUtils.createNullableSchema(schema.toAvroSchema());
- return HoodieSchema.fromAvroSchema(nullableAvro);
- }
-
- /**
- * Create a new schema by force changing all the fields as nullable.
- *
- * @return a new schema with all the fields updated as nullable
- * @throws IllegalArgumentException if schema is null
- * @see AvroSchemaUtils#asNullable(Schema)
+ * Create a new schema by force changing all the top-level fields as
nullable.
+ *
+ * <p>The rewrite runs through the field-id {@link InternalSchema}: the
record is converted, every
+ * still-required top-level field is marked nullable with a {@link
TableChanges.ColumnUpdateChange},
+ * and the updated InternalSchema is converted back under the original full
name. Only the top level
+ * changes - the inner fields of a nested record keep the nullability they
had. Because the record is
+ * rebuilt from the InternalSchema, its full name, field order and per-field
docs survive, while the
+ * record-level doc and any custom record properties do not. Three more
effects of that round trip are
+ * pre-existing and pinned by tests: a non-null field default becomes {@code
null}, an ENUM field comes
+ * back as STRING, and an already-nullable null-last union is reordered
null-first.</p>
+ *
+ * <p>When every top-level field is already nullable the input instance
itself is returned and no
+ * conversion runs.</p>
+ *
+ * @param schema original schema
+ * @return a schema with all the top-level fields updated as nullable, or
{@code schema} itself when
+ * there is nothing to change
+ * @throws IllegalArgumentException if schema is null or not a RECORD
*/
public static HoodieSchema asNullable(HoodieSchema schema) {
ValidationUtils.checkArgument(schema != null, "Schema cannot be null");
+ ValidationUtils.checkArgument(schema.getType() == HoodieSchemaType.RECORD,
+ "asNullable expects a RECORD schema, got: " + schema.getType());
+
+ // NOTE: HoodieSchema#isNullable is false for a bare NULL type, unlike
Avro's Schema#isNullable, so a
+ // NULL-typed field is excluded explicitly to keep it out of the
update list as it always was.
+ List<String> requiredCols = schema.getFields().stream()
+ .filter(f -> !(f.schema().isNullable() || f.schema().getType() ==
HoodieSchemaType.NULL))
+ .map(HoodieSchemaField::name)
+ .collect(Collectors.toList());
+ if (requiredCols.isEmpty()) {
+ return schema;
+ }
- // Delegate to AvroSchemaUtils
- Schema nullableAvro = AvroSchemaUtils.asNullable(schema.toAvroSchema());
- return HoodieSchema.fromAvroSchema(nullableAvro);
+ InternalSchema internalSchema = InternalSchemaConverter.convert(schema);
+ TableChanges.ColumnUpdateChange schemaChange =
TableChanges.ColumnUpdateChange.get(internalSchema);
+ schemaChange = CollectionUtils.reduce(requiredCols, schemaChange,
+ (change, field) -> change.updateColumnNullability(field, true));
+ return InternalSchemaConverter.convert(
+ SchemaChangeUtils.applyTableChanges2Schema(internalSchema,
schemaChange), schema.getFullName());
}
/**
@@ -398,7 +414,7 @@ public final class HoodieSchemaUtils {
* Alias of {@link HoodieSchemaField#of(String, HoodieSchema, String,
Object, HoodieFieldOrder)} with
* argument validation. Prefer {@code HoodieSchemaField.of} directly in new
code; this overload still
* round-trips through the Avro-typed {@code
HoodieAvroUtils#createNewSchemaField} and is being retired
- * under #16639.
+ * under #14263.
*
* @param name field name
* @param schema field schema
@@ -948,6 +964,15 @@ public final class HoodieSchemaUtils {
return
INVALID_AVRO_CHARS_IN_NAMES_PATTERN.matcher(name).replaceAll(invalidCharMask);
}
+ /**
+ * Formats a schema error with the writer and table schemas appended on
their own lines, so callers
+ * throwing {@code SchemaCompatibilityException} report both sides in a
consistent shape.
+ *
+ * @param errorMessage the message to lead with
+ * @param writerSchema the incoming writer schema
+ * @param tableSchema the current table schema
+ * @return the message followed by both schemas, one per line
+ */
public static String createSchemaErrorString(String errorMessage,
HoodieSchema writerSchema, HoodieSchema tableSchema) {
return String.format("%s\nwriterSchema: %s\ntableSchema: %s",
errorMessage, writerSchema, tableSchema);
}
diff --git
a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java
b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java
index 6c87d584765a..655fa6e58a9a 100644
---
a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java
+++
b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java
@@ -328,6 +328,9 @@ public class HoodieTableMetadataUtil {
Object fieldValue;
HoodieSchemaType fieldSchemaType = fieldSchema.getType();
if (record.getRecordType() == HoodieRecordType.AVRO) {
+ // Deliberately the static helper rather than record.getColumnValues,
which the SPARK branch below uses:
+ // HoodieAvroIndexedRecord also reports type AVRO but overrides
getColumnValues with a decode plus
+ // AvroRecordContext.getFieldValueFromIndexedRecord, so dispatching
would read nested fields differently.
fieldValue = HoodieAvroUtils.getRecordColumnValues(record, new
String[]{fieldName}, recordSchema, false)[0];
if (fieldValue != null && fieldSchemaType.equals(HoodieSchemaType.DATE))
{
fieldValue = java.sql.Date.valueOf(fieldValue.toString());
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/avro/TestAvroSchemaUtils.java
b/hudi-common/src/test/java/org/apache/hudi/common/avro/TestAvroSchemaUtils.java
deleted file mode 100644
index 9ff2d9fb768e..000000000000
---
a/hudi-common/src/test/java/org/apache/hudi/common/avro/TestAvroSchemaUtils.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.hudi.common.avro;
-
-import org.apache.avro.Schema;
-import org.junit.jupiter.api.Test;
-
-import java.util.Collections;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-public class TestAvroSchemaUtils {
-
- @Test
- public void testCreateNewSchemaFromFieldsWithReference_NullSchema() {
- // This test should throw an IllegalArgumentException
- assertThrows(IllegalArgumentException.class, () ->
AvroSchemaUtils.createNewSchemaFromFieldsWithReference(null,
Collections.emptyList()));
- }
-
- @Test
- public void testCreateNewSchemaFromFieldsWithReference_NullObjectProps() {
- // Create a schema without any object properties
- String schemaStr = "{ \"type\": \"record\", \"name\": \"TestRecord\",
\"fields\": [] }";
- Schema schema = new Schema.Parser().parse(schemaStr);
-
- // Ensure getObjectProps returns null by mocking or creating a schema
without props
- Schema newSchema =
AvroSchemaUtils.createNewSchemaFromFieldsWithReference(schema,
Collections.emptyList());
-
- // Validate the new schema
- assertEquals("TestRecord", newSchema.getName());
- assertEquals(0, newSchema.getFields().size());
- }
-
- @Test
- public void testCreateNewSchemaFromFieldsWithReference_WithObjectProps() {
- // Create a schema with object properties
- String schemaStr = "{ \"type\": \"record\", \"name\": \"TestRecord\",
\"fields\": [], \"prop1\": \"value1\" }";
- Schema schema = new Schema.Parser().parse(schemaStr);
-
- // Add an object property to the schema
- schema.addProp("prop1", "value1");
-
- // Create new fields to add
- Schema.Field newField = new Schema.Field("newField",
Schema.create(Schema.Type.STRING), null, (Object) null);
- Schema newSchema =
AvroSchemaUtils.createNewSchemaFromFieldsWithReference(schema,
Collections.singletonList(newField));
-
- // Validate the new schema
- assertEquals("TestRecord", newSchema.getName());
- assertEquals(1, newSchema.getFields().size());
- assertEquals("value1", newSchema.getProp("prop1"));
- assertEquals("newField", newSchema.getFields().get(0).name());
- }
-}
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java
b/hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java
index d59171dd653a..f899e585b46c 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java
@@ -68,6 +68,7 @@ import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.schema.HoodieSchemaType;
import org.apache.hudi.common.schema.HoodieSchemaUtils;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.exception.HoodieAvroSchemaException;
import org.apache.hudi.exception.HoodieException;
import org.apache.hudi.exception.SchemaCompatibilityException;
@@ -119,7 +120,7 @@ import java.util.Random;
import java.util.stream.Collectors;
import java.util.stream.Stream;
-import static
org.apache.hudi.common.avro.AvroSchemaUtils.getNonNullTypeFromUnion;
+import static
org.apache.hudi.common.avro.HoodieAvroUtils.getNonNullTypeFromUnion;
import static
org.apache.hudi.common.avro.HoodieAvroWrapperUtils.unwrapAvroValueWrapper;
import static
org.apache.hudi.common.avro.HoodieAvroWrapperUtils.wrapValueIntoAvro;
import static org.apache.hudi.common.schema.HoodieSchemaUtils.sanitizeName;
@@ -129,6 +130,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -1366,4 +1368,74 @@ public class TestHoodieAvroUtils {
// the pom.properties lookup must agree with the jar that actually defines
Schema
assertEquals(Schema.class.getPackage().getImplementationVersion(),
HoodieAvroUtils.AVRO_VERSION);
}
+
+ @Test
+ public void testCreateNewSchemaFromFieldsWithReference_NullSchema() {
+ // This test should throw an IllegalArgumentException
+ assertThrows(IllegalArgumentException.class, () ->
HoodieAvroUtils.createNewSchemaFromFieldsWithReference(null,
Collections.emptyList()));
+ }
+
+ @Test
+ public void testCreateNewSchemaFromFieldsWithReference_NullObjectProps() {
+ // Create a schema without any object properties
+ String schemaStr = "{ \"type\": \"record\", \"name\": \"TestRecord\",
\"fields\": [] }";
+ Schema schema = new Schema.Parser().parse(schemaStr);
+
+ // Ensure getObjectProps returns null by mocking or creating a schema
without props
+ Schema newSchema =
HoodieAvroUtils.createNewSchemaFromFieldsWithReference(schema,
Collections.emptyList());
+
+ // Validate the new schema
+ assertEquals("TestRecord", newSchema.getName());
+ assertEquals(0, newSchema.getFields().size());
+ }
+
+ @Test
+ public void testCreateNewSchemaFromFieldsWithReference_WithObjectProps() {
+ // Create a schema with object properties
+ String schemaStr = "{ \"type\": \"record\", \"name\": \"TestRecord\",
\"fields\": [], \"prop1\": \"value1\" }";
+ Schema schema = new Schema.Parser().parse(schemaStr);
+
+ // Add an object property to the schema
+ schema.addProp("prop1", "value1");
+
+ // Create new fields to add
+ Schema.Field newField = new Schema.Field("newField",
Schema.create(Schema.Type.STRING), null, (Object) null);
+ Schema newSchema =
HoodieAvroUtils.createNewSchemaFromFieldsWithReference(schema,
Collections.singletonList(newField));
+
+ // Validate the new schema
+ assertEquals("TestRecord", newSchema.getName());
+ assertEquals(1, newSchema.getFields().size());
+ assertEquals("value1", newSchema.getProp("prop1"));
+ assertEquals("newField", newSchema.getFields().get(0).name());
+ }
+
+ @Test
+ public void testGetNonNullTypeFromUnionReturnsNonUnionAsIs() {
+ Schema intSchema = Schema.create(Schema.Type.INT);
+ assertSame(intSchema, getNonNullTypeFromUnion(intSchema));
+ }
+
+ @Test
+ public void testGetNonNullTypeFromUnionUnwrapsBothBranchOrders() {
+ Schema intSchema = Schema.create(Schema.Type.INT);
+ Schema nullSchema = Schema.create(Schema.Type.NULL);
+ assertSame(intSchema,
getNonNullTypeFromUnion(Schema.createUnion(nullSchema, intSchema)));
+ assertSame(intSchema,
getNonNullTypeFromUnion(Schema.createUnion(intSchema, nullSchema)));
+ }
+
+ @Test
+ public void testGetNonNullTypeFromUnionRejectsUnsupportedUnions() {
+ List<Schema> unsupported = Arrays.asList(
+ // more than two branches, even with a null one
+ Schema.createUnion(Schema.create(Schema.Type.NULL),
Schema.create(Schema.Type.INT), Schema.create(Schema.Type.STRING)),
+ // exactly two branches, but neither of them is null
+ Schema.createUnion(Schema.create(Schema.Type.INT),
Schema.create(Schema.Type.STRING)),
+ // a single branch is not a nullable union either
+ Schema.createUnion(Schema.create(Schema.Type.INT)));
+ for (Schema schema : unsupported) {
+ HoodieAvroSchemaException e =
+ assertThrows(HoodieAvroSchemaException.class, () ->
getNonNullTypeFromUnion(schema));
+ assertTrue(e.getMessage().contains("Only UNION of a null type and a
non-null type"), e.getMessage());
+ }
+ }
}
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchema.java
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchema.java
index fded6184026a..c5e9b8b090b2 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchema.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchema.java
@@ -75,6 +75,17 @@ public class TestHoodieSchema {
+ " ]"
+ "}";
+ // The Blob schema is persisted in the table schema of every BLOB table, so
its serialized shape is pinned literally.
+ private static final String EXPECTED_BLOB_SCHEMA_JSON =
"{\"type\":\"record\",\"name\":\"blob\",\"fields\":["
+ +
"{\"name\":\"type\",\"type\":{\"type\":\"enum\",\"name\":\"blob_storage_type\",\"symbols\":[\"INLINE\",\"OUT_OF_LINE\"]}},"
+ + "{\"name\":\"data\",\"type\":[\"null\",\"bytes\"],\"default\":null},"
+ +
"{\"name\":\"reference\",\"type\":[\"null\",{\"type\":\"record\",\"name\":\"reference\",\"fields\":["
+ + "{\"name\":\"external_path\",\"type\":\"string\"},"
+ + "{\"name\":\"offset\",\"type\":[\"null\",\"long\"]},"
+ + "{\"name\":\"length\",\"type\":[\"null\",\"long\"]},"
+ + "{\"name\":\"managed\",\"type\":\"boolean\"}]}],\"default\":null}],"
+ + "\"logicalType\":\"blob\"}";
+
/**
* Checks if the given Avro schema is a Variant schema. This checks for the
Variant logical type.
*
@@ -2485,8 +2496,10 @@ public class TestHoodieSchema {
@Test
public void testBlobFieldCountMethods() {
- assertTrue(HoodieSchema.Blob.getFieldCount() > 0);
- assertTrue(HoodieSchema.Blob.getReferenceFieldCount() > 0);
+ // type, data, reference
+ assertEquals(3, HoodieSchema.Blob.getFieldCount());
+ // external_path, offset, length, managed
+ assertEquals(4, HoodieSchema.Blob.getReferenceFieldCount());
}
@Test
@@ -2550,6 +2563,10 @@ public class TestHoodieSchema {
assertTrue(managedOpt.isPresent());
assertEquals(HoodieSchemaType.BOOLEAN,
managedOpt.get().schema().getType());
assertFalse(managedOpt.get().schema().isNullable());
+
+ // Enum symbols, null-first unions, the null defaults on data/reference
(and none on offset/length),
+ // the reference record name and the blob logical type, pinned as the
persisted string.
+ assertEquals(EXPECTED_BLOB_SCHEMA_JSON, blob.toAvroSchema().toString());
}
@Test
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaCompatibility.java
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaCompatibility.java
index 5be6716c2e7a..67ba3059228d 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaCompatibility.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaCompatibility.java
@@ -19,6 +19,7 @@
package org.apache.hudi.common.schema;
import org.apache.hudi.common.avro.VariantSchemaUtils;
+import org.apache.hudi.common.schema.internal.HoodieSchemaException;
import org.apache.hudi.exception.SchemaBackwardsCompatibilityException;
import org.apache.hudi.exception.SchemaCompatibilityException;
@@ -42,6 +43,7 @@ import static
org.apache.hudi.common.schema.TestHoodieSchemaUtils.SIMPLE_SCHEMA;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -701,6 +703,157 @@ public class TestHoodieSchemaCompatibility {
assertFalse(HoodieSchemaCompatibility.isSchemaCompatible(longS, intS,
true, true));
}
+ /**
+ * Sibling of {@link #testIsSchemaCompatibleWithTypePromotion()} covering
the rest of the reader/writer type
+ * table: the primitive widening cases shared with {@link
HoodieSchemaTypePromotion}, plus the two
+ * logical-type-over-primitive rules (TIMESTAMP over LONG, UUID over STRING)
that are compatibility-only.
+ *
+ * <p>All pairs are asserted through the 4-arg {@code
isSchemaCompatible(prev = writer, new = reader, true, true)}.</p>
+ */
+ @Test
+ public void testIsSchemaCompatibleWithLogicalTypesAndWidening() {
+ // Logical type over its backing primitive: accepted for reader/writer
compatibility.
+ assertCompatible(HoodieSchema.createTimestampMillis(),
HoodieSchema.create(HoodieSchemaType.LONG));
+ assertCompatible(HoodieSchema.createUUID(),
HoodieSchema.create(HoodieSchemaType.STRING));
+
+ // ... but only in that direction, and only over the matching primitive.
+ assertIncompatible(HoodieSchema.create(HoodieSchemaType.LONG),
HoodieSchema.createTimestampMillis());
+ assertIncompatible(HoodieSchema.createTimestampMillis(),
HoodieSchema.create(HoodieSchemaType.INT));
+ // DATE has no such rule at all, even though it is backed by INT.
+ assertIncompatible(HoodieSchema.createDate(),
HoodieSchema.create(HoodieSchemaType.INT));
+
+ // Primitive widening, delegated to HoodieSchemaTypePromotion.
+ assertCompatible(HoodieSchema.create(HoodieSchemaType.DOUBLE),
HoodieSchema.create(HoodieSchemaType.FLOAT));
+ assertIncompatible(HoodieSchema.create(HoodieSchemaType.FLOAT),
HoodieSchema.create(HoodieSchemaType.DOUBLE));
+ // The narrowing direction is rejected for every numeric pair. The
hudi-spark guard for the reversed
+ // argument bug class (TestTableSchemaEvolution, HUDI-1493) never runs, so
the pairs are pinned here.
+ assertIncompatible(HoodieSchema.create(HoodieSchemaType.INT),
HoodieSchema.create(HoodieSchemaType.LONG));
+ assertIncompatible(HoodieSchema.create(HoodieSchemaType.INT),
HoodieSchema.create(HoodieSchemaType.FLOAT));
+ assertIncompatible(HoodieSchema.create(HoodieSchemaType.INT),
HoodieSchema.create(HoodieSchemaType.DOUBLE));
+ assertIncompatible(HoodieSchema.create(HoodieSchemaType.LONG),
HoodieSchema.create(HoodieSchemaType.FLOAT));
+ assertIncompatible(HoodieSchema.create(HoodieSchemaType.LONG),
HoodieSchema.create(HoodieSchemaType.DOUBLE));
+ assertCompatible(HoodieSchema.create(HoodieSchemaType.STRING),
HoodieSchema.create(HoodieSchemaType.BYTES));
+ assertCompatible(HoodieSchema.create(HoodieSchemaType.BYTES),
HoodieSchema.create(HoodieSchemaType.STRING));
+ assertCompatible(HoodieSchema.create(HoodieSchemaType.STRING),
HoodieSchema.create(HoodieSchemaType.INT));
+ }
+
+ @Test
+ public void testAreSchemasCompatibleReaderIsFirstArgument() {
+ HoodieSchema longRecord = HoodieSchemaTestUtils.createRecord("R",
HoodieSchemaField.of("f", HoodieSchema.create(HoodieSchemaType.LONG), null,
null));
+ HoodieSchema intRecord = HoodieSchemaTestUtils.createRecord("R",
HoodieSchemaField.of("f", HoodieSchema.create(HoodieSchemaType.INT), null,
null));
+
+ // A long reader can read int data ...
+ assertTrue(HoodieSchemaCompatibility.areSchemasCompatible(longRecord,
intRecord));
+ // ... but not the other way round, which pins the reader as the FIRST
argument.
+ assertFalse(HoodieSchemaCompatibility.areSchemasCompatible(intRecord,
longRecord));
+ }
+
+ @Test
+ public void testLookupWriterFieldDirectMatch() {
+ HoodieSchemaField readerField = readerFieldWithAlias();
+ HoodieSchema writerSchema =
HoodieSchema.parse("{\"type\":\"record\",\"name\":\"W\",\"fields\":["
+ + "{\"name\":\"a\",\"type\":\"int\"}]}");
+
+ HoodieSchemaField writerField =
HoodieSchemaCompatibility.lookupWriterField(writerSchema, readerField);
+ assertEquals("a", writerField.name());
+ }
+
+ /**
+ * The alias path is also driven end to end through the sole production
caller,
+ * {@code HoodieTable#validateSecondaryIndexSchemaEvolution} (see
+ * {@code TestHoodieTableSchemaEvolution#testFieldWithAlias}, whose
type-change half is what makes the alias
+ * match decide the outcome: an unresolved alias returns null and is skipped
by that caller's
+ * {@code writerField != null} guard); this case pins the facade on its own.
+ */
+ @Test
+ public void testLookupWriterFieldAliasMatch() {
+ HoodieSchemaField readerField = readerFieldWithAlias();
+ HoodieSchema writerSchema =
HoodieSchema.parse("{\"type\":\"record\",\"name\":\"W\",\"fields\":["
+ + "{\"name\":\"old_a\",\"type\":\"int\"}]}");
+
+ HoodieSchemaField writerField =
HoodieSchemaCompatibility.lookupWriterField(writerSchema, readerField);
+ assertEquals("old_a", writerField.name());
+ }
+
+ @Test
+ public void testLookupWriterFieldAmbiguousMatchThrows() {
+ HoodieSchemaField readerField = readerFieldWithAlias();
+ HoodieSchema writerSchema =
HoodieSchema.parse("{\"type\":\"record\",\"name\":\"W\",\"fields\":["
+ + "{\"name\":\"a\",\"type\":\"int\"},"
+ + "{\"name\":\"old_a\",\"type\":\"int\"}]}");
+
+ assertThrows(HoodieSchemaException.class,
+ () -> HoodieSchemaCompatibility.lookupWriterField(writerSchema,
readerField));
+ }
+
+ @Test
+ public void testLookupWriterFieldNoMatchReturnsNull() {
+ HoodieSchemaField readerField = readerFieldWithAlias();
+ HoodieSchema writerSchema =
HoodieSchema.parse("{\"type\":\"record\",\"name\":\"W\",\"fields\":["
+ + "{\"name\":\"unrelated\",\"type\":\"int\"}]}");
+
+ assertNull(HoodieSchemaCompatibility.lookupWriterField(writerSchema,
readerField));
+ }
+
+ @Test
+ public void testLookupWriterFieldRejectsNonRecordWriterSchema() {
+ HoodieSchemaField readerField = readerFieldWithAlias();
+ HoodieSchema notARecord = HoodieSchema.create(HoodieSchemaType.STRING);
+
+ assertThrows(IllegalArgumentException.class,
+ () -> HoodieSchemaCompatibility.lookupWriterField(notARecord,
readerField));
+ }
+
+ /**
+ * Reader field {@code a}, aliased {@code old_a}. Aliases have no builder on
HoodieSchemaField, so the
+ * reader record is parsed from JSON.
+ */
+ private static HoodieSchemaField readerFieldWithAlias() {
+ HoodieSchema readerSchema =
HoodieSchema.parse("{\"type\":\"record\",\"name\":\"R\",\"fields\":["
+ + "{\"name\":\"a\",\"type\":\"int\",\"aliases\":[\"old_a\"]}]}");
+ return readerSchema.getField("a").get();
+ }
+
+ /**
+ * Asserts that a field written as {@code writerFieldSchema} can be read
back as {@code readerFieldSchema},
+ * that is, the {@code writer -> reader} conversion is allowed.
+ *
+ * <p>Arguments are in reader-first ("to", "from") order, matching
+ * {@link HoodieSchemaCompatibility#areSchemasCompatible(HoodieSchema,
HoodieSchema)}. That is the reverse
+ * of the (prev = writer, new = reader) order taken by
+ * {@link HoodieSchemaCompatibility#isSchemaCompatible(HoodieSchema,
HoodieSchema, boolean, boolean)};
+ * the helper flips the pair before calling it.</p>
+ *
+ * <p>Both schemas are wrapped in a single-field record {@code R { f }}, so
only the per-field type rules
+ * are exercised here. Naming checks and projection are both enabled; field
addition, removal and renaming
+ * are covered by the other tests in this class.</p>
+ *
+ * @param readerFieldSchema type the field is read as (the "to" side of the
conversion)
+ * @param writerFieldSchema type the field was written with (the "from" side)
+ */
+ private static void assertCompatible(HoodieSchema readerFieldSchema,
HoodieSchema writerFieldSchema) {
+ assertTrue(HoodieSchemaCompatibility.isSchemaCompatible(
+ HoodieSchemaTestUtils.createRecord("R", HoodieSchemaField.of("f",
writerFieldSchema, null, null)),
+ HoodieSchemaTestUtils.createRecord("R", HoodieSchemaField.of("f",
readerFieldSchema, null, null)), true, true),
+ "reader " + readerFieldSchema + " should read writer " +
writerFieldSchema);
+ }
+
+ /**
+ * Negative counterpart of {@link #assertCompatible(HoodieSchema,
HoodieSchema)}: asserts that a field
+ * written as {@code writerFieldSchema} cannot be read back as {@code
readerFieldSchema}, that is, the
+ * {@code writer -> reader} conversion is rejected. Same reader-first
argument order and same single-field
+ * record wrapping.
+ *
+ * @param readerFieldSchema type the field would be read as (the "to" side
of the conversion)
+ * @param writerFieldSchema type the field was written with (the "from" side)
+ */
+ private static void assertIncompatible(HoodieSchema readerFieldSchema,
HoodieSchema writerFieldSchema) {
+ assertFalse(HoodieSchemaCompatibility.isSchemaCompatible(
+ HoodieSchemaTestUtils.createRecord("R", HoodieSchemaField.of("f",
writerFieldSchema, null, null)),
+ HoodieSchemaTestUtils.createRecord("R", HoodieSchemaField.of("f",
readerFieldSchema, null, null)), true, true),
+ "reader " + readerFieldSchema + " should not read writer " +
writerFieldSchema);
+ }
+
@Test
public void testIsSchemaCompatibleWithNestedSchemas() {
// Test with nested record schemas
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaTypePromotion.java
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaTypePromotion.java
index e6215b9a19aa..9286f1c03b3f 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaTypePromotion.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaTypePromotion.java
@@ -86,6 +86,15 @@ public class TestHoodieSchemaTypePromotion {
assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.BOOLEAN,
HoodieSchemaType.INT));
assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.INT,
HoodieSchemaType.BOOLEAN));
assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.LONG,
HoodieSchemaType.STRING));
+ // Logical-type-over-primitive pairs are reader/writer compatibility rules
only (see
+ // HoodieSchemaCompatibilityChecker); they must never be reported as
compatible projections.
+
assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.TIMESTAMP,
HoodieSchemaType.LONG));
+ assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.UUID,
HoodieSchemaType.STRING));
+ assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.DATE,
HoodieSchemaType.INT));
+ // The same holds on the writer side: an int-backed logical type is never
a plain INT to the widening
+ // table, which is what keeps the checker's LONG/FLOAT/DOUBLE cases from
accepting it.
+ assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.LONG,
HoodieSchemaType.DATE));
+ assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.LONG,
HoodieSchemaType.TIME));
}
@Test
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java
index 1862f2c80a33..99d1df06055f 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java
@@ -24,6 +24,7 @@ import
org.apache.hudi.common.testutils.HoodieTestDataGenerator;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.HoodieNullSchemaTypeException;
import org.apache.avro.generic.GenericRecord;
import org.junit.jupiter.api.Test;
@@ -50,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -2084,6 +2086,178 @@ public class TestHoodieSchemaUtils {
assertEquals(HoodieSchemaType.LONG,
result.get().getRight().schema().getType());
}
+ /**
+ * Record with a namespace, a record-level doc, a custom record prop,
per-field docs and a nested
+ * record - all of its top-level fields required.
+ */
+ private static HoodieSchema allRequiredPersonSchema() {
+ HoodieSchema address = HoodieSchema.createRecord(
+ "Address",
+ "ns.test",
+ "the address record",
+ Arrays.asList(
+ HoodieSchemaField.of("city",
HoodieSchema.create(HoodieSchemaType.STRING), "city doc", null),
+ HoodieSchemaField.of("zip",
HoodieSchema.create(HoodieSchemaType.INT), null, null)));
+ HoodieSchema schema = HoodieSchema.createRecord(
+ "Person",
+ "ns.test",
+ "the person record",
+ Arrays.asList(
+ HoodieSchemaField.of("id",
HoodieSchema.create(HoodieSchemaType.INT), "id doc", null),
+ HoodieSchemaField.of("name",
HoodieSchema.create(HoodieSchemaType.STRING), null, null),
+ HoodieSchemaField.of("address", address, "address doc", null)));
+ schema.addProp("hoodie.custom.prop", "custom-value");
+ return schema;
+ }
+
+ @Test
+ public void testAsNullableMakesEveryTopLevelFieldNullable() {
+ HoodieSchema schema = allRequiredPersonSchema();
+
+ HoodieSchema nullable = HoodieSchemaUtils.asNullable(schema);
+
+ assertNotSame(schema, nullable);
+ assertEquals("Person", nullable.getName());
+ assertEquals("ns.test", nullable.getNamespace().get());
+ assertEquals("ns.test.Person", nullable.getFullName());
+ assertEquals(Arrays.asList("id", "name", "address"),
+
nullable.getFields().stream().map(HoodieSchemaField::name).collect(Collectors.toList()));
+
+ for (HoodieSchemaField field : nullable.getFields()) {
+ assertTrue(field.isNullable(), "Field " + field.name() + " should be
nullable");
+ assertEquals(HoodieSchema.NULL_VALUE, field.defaultVal().get());
+ }
+ assertEquals(HoodieSchemaType.INT,
nullable.getField("id").get().getNonNullSchema().getType());
+ assertEquals(HoodieSchemaType.STRING,
nullable.getField("name").get().getNonNullSchema().getType());
+
+ // Per-field docs survive the InternalSchema round trip.
+ assertEquals("id doc", nullable.getField("id").get().doc().get());
+ assertFalse(nullable.getField("name").get().doc().isPresent());
+ assertEquals("address doc",
nullable.getField("address").get().doc().get());
+
+ // Only the top level changes: the nested record keeps its own required
fields.
+ HoodieSchema nestedAddress =
nullable.getField("address").get().getNonNullSchema();
+ assertEquals(HoodieSchemaType.RECORD, nestedAddress.getType());
+ assertEquals("ns.test.Address", nestedAddress.getFullName());
+ assertFalse(nestedAddress.getField("city").get().isNullable());
+ assertFalse(nestedAddress.getField("zip").get().isNullable());
+ assertEquals("city doc", nestedAddress.getField("city").get().doc().get());
+
+ // The InternalSchema carries neither a record doc nor record props, so
both are dropped. This is
+ // the behaviour the Avro-typed implementation had as well, since it ran
the same conversion.
+ assertFalse(nullable.getDoc().isPresent());
+ assertTrue(nullable.getObjectProps().isEmpty());
+
+ // The input schema is left untouched.
+ assertEquals("the person record", schema.getDoc().get());
+ assertEquals("custom-value",
schema.getObjectProps().get("hoodie.custom.prop"));
+ assertFalse(schema.getField("id").get().isNullable());
+ }
+
+ @Test
+ public void testAsNullableReturnsSameInstanceWhenAllFieldsAlreadyNullable() {
+ HoodieSchema schema = HoodieSchema.createRecord(
+ "AllNullable",
+ "ns.test",
+ null,
+ Arrays.asList(
+ HoodieSchemaField.of("id",
HoodieSchema.createNullable(HoodieSchemaType.INT), null,
HoodieSchema.NULL_VALUE),
+ HoodieSchemaField.of("name",
HoodieSchema.createNullable(HoodieSchemaType.STRING), "name doc",
HoodieSchema.NULL_VALUE)));
+
+ assertSame(schema, HoodieSchemaUtils.asNullable(schema));
+ }
+
+ @Test
+ public void testAsNullableLeavesAlreadyNullableFieldsUntouched() {
+ HoodieSchema nullableName =
HoodieSchema.createNullable(HoodieSchemaType.STRING);
+ HoodieSchema schema = HoodieSchema.createRecord(
+ "Mixed",
+ "ns.test",
+ null,
+ Arrays.asList(
+ HoodieSchemaField.of("optional_name", nullableName, "name doc",
HoodieSchema.NULL_VALUE),
+ HoodieSchemaField.of("required_id",
HoodieSchema.create(HoodieSchemaType.LONG), null, null)));
+
+ HoodieSchema nullable = HoodieSchemaUtils.asNullable(schema);
+
+ assertEquals(nullableName,
nullable.getField("optional_name").get().schema());
+ assertEquals("name doc",
nullable.getField("optional_name").get().doc().get());
+
+ HoodieSchemaField requiredId = nullable.getField("required_id").get();
+ assertTrue(requiredId.isNullable());
+ assertEquals(HoodieSchemaType.LONG,
requiredId.getNonNullSchema().getType());
+ }
+
+ @Test
+ public void testAsNullableTreatsBareNullFieldAsAlreadyNullable() {
+ // Avro's Schema#isNullable is true for a bare NULL type while
HoodieSchema#isNullable is not, so a
+ // record made only of NULL-typed fields must still short-circuit rather
than attempt a conversion.
+ HoodieSchema schema = HoodieSchema.createRecord(
+ "OnlyNull",
+ "ns.test",
+ null,
+ Collections.singletonList(
+ HoodieSchemaField.of("nothing",
HoodieSchema.create(HoodieSchemaType.NULL), null, null)));
+
+ assertSame(schema, HoodieSchemaUtils.asNullable(schema));
+ }
+
+ @Test
+ public void testAsNullableRejectsBareNullFieldAlongsideARequiredField() {
+ // A NULL-typed field is never added to the update list, but as soon as
some other field does need
+ // updating the InternalSchema conversion runs and rejects the NULL type
outright. Pinned because it
+ // is what the previous Avro-typed implementation did too.
+ HoodieSchema schema = HoodieSchema.createRecord(
+ "NullAndRequired",
+ "ns.test",
+ null,
+ Arrays.asList(
+ HoodieSchemaField.of("nothing",
HoodieSchema.create(HoodieSchemaType.NULL), null, null),
+ HoodieSchemaField.of("id",
HoodieSchema.create(HoodieSchemaType.INT), null, null)));
+
+ HoodieNullSchemaTypeException exception =
assertThrows(HoodieNullSchemaTypeException.class,
+ () -> HoodieSchemaUtils.asNullable(schema));
+ assertTrue(exception.getMessage().contains("nothing"),
exception.getMessage());
+ }
+
+ @Test
+ public void testAsNullablePinsTheInternalSchemaRoundTripLosses() {
+ // All three losses are what the previous Avro-typed implementation
produced as well: the
+ // InternalSchema has no field defaults, no ENUM type and no union branch
order of its own.
+ HoodieSchema schema = HoodieSchema.createRecord(
+ "Lossy",
+ "ns.test",
+ null,
+ Arrays.asList(
+ HoodieSchemaField.of("count",
HoodieSchema.create(HoodieSchemaType.INT), null, 0),
+ HoodieSchemaField.of("kind", HoodieSchema.createEnum("Kind",
"ns.test", null, Arrays.asList("A", "B")), null, null),
+ HoodieSchemaField.of("null_last",
+
HoodieSchema.createUnion(HoodieSchema.create(HoodieSchemaType.STRING),
HoodieSchema.create(HoodieSchemaType.NULL)), null, null),
+ HoodieSchemaField.of("embedding", HoodieSchema.createVector(3),
null, null)));
+
+ HoodieSchema nullable = HoodieSchemaUtils.asNullable(schema);
+
+ // A non-null default is replaced by the null default of the new union.
+ assertEquals(HoodieSchema.NULL_VALUE,
nullable.getField("count").get().defaultVal().get());
+ // ENUM is lowered to STRING.
+ assertEquals(HoodieSchemaType.STRING,
nullable.getField("kind").get().getNonNullSchema().getType());
+ // An already-nullable null-last union comes back null-first.
+ assertEquals(Arrays.asList(HoodieSchemaType.NULL, HoodieSchemaType.STRING),
+
nullable.getField("null_last").get().schema().getTypes().stream().map(HoodieSchema::getType).collect(Collectors.toList()));
+ // A VECTOR column, the Flink clustering case, survives with its logical
type and dimension.
+ HoodieSchema embedding =
nullable.getField("embedding").get().getNonNullSchema();
+ assertEquals(HoodieSchemaType.VECTOR, embedding.getType());
+ assertEquals(3, ((HoodieSchema.Vector) embedding).getDimension());
+ }
+
+ @Test
+ public void testAsNullableRejectsNonRecordSchema() {
+ assertThrows(IllegalArgumentException.class,
+ () ->
HoodieSchemaUtils.asNullable(HoodieSchema.create(HoodieSchemaType.STRING)));
+ assertThrows(IllegalArgumentException.class,
+ () ->
HoodieSchemaUtils.asNullable(HoodieSchema.createNullable(allRequiredPersonSchema())));
+ }
+
private static HoodieSchema deleteLogTableSchema() {
return HoodieSchema.createRecord(
"TestRecord",
diff --git
a/hudi-hadoop-common/src/test/java/org/apache/hudi/avro/TestHoodieAvroWriteSupportShredding.java
b/hudi-hadoop-common/src/test/java/org/apache/hudi/avro/TestHoodieAvroWriteSupportShredding.java
index e4ae84422fbc..4c35a5300d6d 100644
---
a/hudi-hadoop-common/src/test/java/org/apache/hudi/avro/TestHoodieAvroWriteSupportShredding.java
+++
b/hudi-hadoop-common/src/test/java/org/apache/hudi/avro/TestHoodieAvroWriteSupportShredding.java
@@ -65,7 +65,7 @@ import java.util.Map;
import java.util.Properties;
import java.util.stream.Collectors;
-import static
org.apache.hudi.common.avro.AvroSchemaUtils.getNonNullTypeFromUnion;
+import static
org.apache.hudi.common.avro.HoodieAvroUtils.getNonNullTypeFromUnion;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
diff --git
a/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java
b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java
index e569d9b7f753..103a36d3fd9e 100644
---
a/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java
+++
b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java
@@ -357,11 +357,15 @@ public class TestHoodieMetadataPayload extends
HoodieCommonTestHarness {
"record-key", PARTITION_NAME, "not-a-uuid", "20240101000000000",
0));
}
- @Test
- public void testProjectedInsertValueIncludesBloomFilter() throws IOException
{
- HoodieMetadataPayload bloomFilterPayload =
HoodieMetadataPayload.createBloomFilterMetadataRecord(
+ private static HoodieMetadataPayload newBloomFilterPayload() {
+ return HoodieMetadataPayload.createBloomFilterMetadataRecord(
PARTITION_NAME, "file-id_1-0-1_20240101000000000.parquet",
"20240101000000000", "SIMPLE",
ByteBuffer.wrap("bloom-data".getBytes()), false).getData();
+ }
+
+ @Test
+ public void testProjectedInsertValueIncludesBloomFilter() throws IOException
{
+ HoodieMetadataPayload bloomFilterPayload = newBloomFilterPayload();
Schema projectedSchema = HoodieSchemaUtils.addMetadataFields(
HoodieSchema.fromAvroSchema(HoodieMetadataRecord.getClassSchema())).toAvroSchema();
@@ -373,13 +377,12 @@ public class TestHoodieMetadataPayload extends
HoodieCommonTestHarness {
@Test
public void testInsertValueFastPathOnlyForClassSchema() throws IOException {
- HoodieMetadataPayload payload =
HoodieMetadataPayload.createBloomFilterMetadataRecord(
- PARTITION_NAME, "file-id_1-0-1_20240101000000000.parquet",
"20240101000000000", "SIMPLE",
- ByteBuffer.wrap("bloom-data".getBytes()), false).getData();
+ HoodieMetadataPayload payload = newBloomFilterPayload();
// The class schema singleton (and no schema) takes the reference-equality
fast path and returns the generated record.
IndexedRecord fastPath =
payload.getInsertValue(HoodieMetadataRecord.getClassSchema()).get();
assertInstanceOf(HoodieMetadataRecord.class, fastPath);
+ // Implied by the check above (the generated class returns SCHEMA$ from
both accessors); pins the invariant.
assertSame(HoodieMetadataRecord.getClassSchema(), fastPath.getSchema());
assertInstanceOf(HoodieMetadataRecord.class,
payload.getInsertValue(null).get());
@@ -388,7 +391,9 @@ public class TestHoodieMetadataPayload extends
HoodieCommonTestHarness {
HoodieSchema.fromAvroSchema(HoodieMetadataRecord.getClassSchema())).toAvroSchema();
assertInstanceOf(GenericData.Record.class,
payload.getInsertValue(withMetaFields).get());
- // So an equal but distinct copy of the bare class schema is not usable:
the fast path has to hit by identity.
+ // So an equal but distinct copy of the bare class schema is not usable.
It misses the identity check, and the
+ // slow path then writes at the offsets the metadata fields would occupy,
which a bare copy does not have:
+ // hence ArrayIndexOutOfBoundsException rather than a wrong-but-returned
record.
Schema equalCopy = new
Schema.Parser().parse(HoodieMetadataRecord.getClassSchema().toString());
assertEquals(HoodieMetadataRecord.getClassSchema(), equalCopy);
assertThrows(ArrayIndexOutOfBoundsException.class, () ->
payload.getInsertValue(equalCopy));
@@ -401,9 +406,7 @@ public class TestHoodieMetadataPayload extends
HoodieCommonTestHarness {
assertTrue(filesPayload.toString().contains("creations=[file.parquet]"));
assertTrue(filesPayload.toString().contains("deletions=[old.parquet]"));
- HoodieMetadataPayload bloomFilterPayload =
HoodieMetadataPayload.createBloomFilterMetadataRecord(
- PARTITION_NAME, "file-id_1-0-1_20240101000000000.parquet",
"20240101000000000", "SIMPLE",
- ByteBuffer.wrap("bloom-data".getBytes()), false).getData();
+ HoodieMetadataPayload bloomFilterPayload = newBloomFilterPayload();
assertTrue(bloomFilterPayload.toString().contains("BloomFilter"));
HoodieColumnRangeMetadata<Comparable> columnRange =
HoodieColumnRangeMetadata.<Comparable>create(