diff --git a/avro/pom.xml b/avro/pom.xml index 248165542..176d1a41f 100644 --- a/avro/pom.xml +++ b/avro/pom.xml @@ -21,6 +21,10 @@ abstractions. + + com.fasterxml.jackson.core + jackson-annotations + com.fasterxml.jackson.core @@ -32,13 +36,7 @@ abstractions. 1.7.7 - - - com.fasterxml.jackson.core - jackson-annotations - test - - + ch.qos.logback logback-classic diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/AvroAnnotationIntrospector.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/AvroAnnotationIntrospector.java index c314a84d1..d56f23347 100644 --- a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/AvroAnnotationIntrospector.java +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/AvroAnnotationIntrospector.java @@ -1,5 +1,6 @@ package com.fasterxml.jackson.dataformat.avro; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.Version; import com.fasterxml.jackson.databind.AnnotationIntrospector; import com.fasterxml.jackson.databind.PropertyName; @@ -9,6 +10,7 @@ import org.apache.avro.reflect.AvroDefault; import org.apache.avro.reflect.AvroIgnore; import org.apache.avro.reflect.AvroName; +import org.apache.avro.reflect.Nullable; /** * Adds support for the following annotations from the Apache Avro implementation: @@ -18,6 +20,7 @@ *
  • {@link AvroDefault @AvroDefault("default value")} - Alias for JsonProperty.defaultValue, to * define default value for generated Schemas *
  • + *
  • {@link Nullable @Nullable} - Alias for JsonProperty(required = false)
  • * * * @since 2.9 @@ -57,4 +60,18 @@ protected PropertyName _findName(Annotated a) AvroName ann = _findAnnotation(a, AvroName.class); return (ann == null) ? null : PropertyName.construct(ann.value()); } + + @Override + public Boolean hasRequiredMarker(AnnotatedMember m) { + if (_hasAnnotation(m, Nullable.class)) { + return false; + } + // Appears to be a bug in POJOPropertyBuilder.getMetadata() + // Can't specify a default unless property is known to be required or not, or we get an NPE + // If we have a default but no annotations indicating required or not, assume required. + if (_hasAnnotation(m, AvroDefault.class) && !_hasAnnotation(m, JsonProperty.class)) { + return true; + } + return null; + } } diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/RecordVisitor.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/RecordVisitor.java index 7c73d84d4..8fa24fc9e 100644 --- a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/RecordVisitor.java +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/RecordVisitor.java @@ -1,14 +1,20 @@ package com.fasterxml.jackson.dataformat.avro.schema; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitable; import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonObjectFormatVisitor; import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; import com.fasterxml.jackson.dataformat.avro.AvroFixedSize; -import org.apache.avro.Schema; -import java.util.ArrayList; -import java.util.List; +import org.apache.avro.Schema; +import org.apache.avro.Schema.Type; +import org.codehaus.jackson.JsonNode; +import org.codehaus.jackson.map.ObjectMapper; public class RecordVisitor extends JsonObjectFormatVisitor.Base @@ -50,7 +56,9 @@ public Schema builtAvroSchema() { public void property(BeanProperty writer) throws JsonMappingException { Schema schema = schemaForWriter(writer); - _fields.add(new Schema.Field(writer.getName(), schema, null, null)); + JsonNode defaultValue = parseJson(getProvider().getAnnotationIntrospector().findPropertyDefaultValue(writer.getMember())); + schema = reorderUnionToMatchDefaultType(schema, defaultValue); + _fields.add(new Schema.Field(writer.getName(), schema, null, defaultValue)); } @Override @@ -73,7 +81,9 @@ public void optionalProperty(BeanProperty writer) throws JsonMappingException { if (!writer.getType().isPrimitive()) { schema = AvroSchemaHelper.unionWithNull(schema); } - _fields.add(new Schema.Field(writer.getName(), schema, null, null)); + JsonNode defaultValue = parseJson(getProvider().getAnnotationIntrospector().findPropertyDefaultValue(writer.getMember())); + schema = reorderUnionToMatchDefaultType(schema, defaultValue); + _fields.add(new Schema.Field(writer.getName(), schema, null, defaultValue)); } @Override @@ -120,4 +130,99 @@ protected Schema schemaForWriter(BeanProperty prop) throws JsonMappingException ser.acceptJsonFormatVisitor(visitor, prop.getType()); return visitor.getAvroSchema(); } + + /** + * Parses a JSON-encoded string for use as the default value of a field + * + * @param defaultValue + * Default value as a JSON-encoded string + * + * @return Jackson V1 {@link JsonNode} for use as the default value in a {@link Schema.Field} + * + * @throws JsonMappingException + * if {@code defaultValue} is not valid JSON + */ + protected JsonNode parseJson(String defaultValue) throws JsonMappingException { + if (defaultValue == null) { + return null; + } + ObjectMapper mapper = new ObjectMapper(); + try { + return mapper.readTree(defaultValue); + } catch (IOException e) { + throw JsonMappingException.from(getProvider(), "Unable to parse default value as JSON: " + defaultValue, e); + } + } + + /** + * A union schema with a default value must always have the schema branch corresponding to the default value first, or Avro will print a + * warning complaining that the default value is not compatible. If {@code schema} is a {@link Schema.Type#UNION UNION} schema and + * {@code defaultValue} is non-{@code null}, this finds the appropriate branch in the union and reorders the union so that it is first. + * + * @param schema + * Schema to reorder; If {@code null} or not a {@code UNION}, then it is returned unmodified. + * @param defaultValue + * Default value to match with the union + * + * @return A schema modified so the first branch matches the type of {@code defaultValue}; otherwise, {@code schema} is returned + * unmodified. + */ + protected Schema reorderUnionToMatchDefaultType(Schema schema, JsonNode defaultValue) { + if (schema == null || defaultValue == null || schema.getType() != Type.UNION) { + return schema; + } + List types = new ArrayList<>(schema.getTypes()); + Integer matchingIndex = null; + if (defaultValue.isArray()) { + matchingIndex = schema.getIndexNamed(Type.ARRAY.getName()); + } else if (defaultValue.isObject()) { + matchingIndex = schema.getIndexNamed(Type.MAP.getName()); + if (matchingIndex == null) { + // search for a record + for (int i = 0; i < types.size(); i++) { + if (types.get(i).getType() == Type.RECORD) { + matchingIndex = i; + break; + } + } + } + } else if (defaultValue.isBoolean()) { + matchingIndex = schema.getIndexNamed(Type.BOOLEAN.getName()); + } else if (defaultValue.isNull()) { + matchingIndex = schema.getIndexNamed(Type.NULL.getName()); + } else if (defaultValue.isBinary()) { + matchingIndex = schema.getIndexNamed(Type.BYTES.getName()); + } else if (defaultValue.isFloatingPointNumber()) { + matchingIndex = schema.getIndexNamed(Type.DOUBLE.getName()); + if (matchingIndex == null) { + matchingIndex = schema.getIndexNamed(Type.FLOAT.getName()); + } + } else if (defaultValue.isIntegralNumber()) { + matchingIndex = schema.getIndexNamed(Type.LONG.getName()); + if (matchingIndex == null) { + matchingIndex = schema.getIndexNamed(Type.INT.getName()); + } + } else if (defaultValue.isTextual()) { + matchingIndex = schema.getIndexNamed(Type.STRING.getName()); + if (matchingIndex == null) { + // search for an enum + for (int i = 0; i < types.size(); i++) { + if (types.get(i).getType() == Type.ENUM) { + matchingIndex = i; + break; + } + } + } + } + if (matchingIndex != null) { + types.add(0, types.remove((int)matchingIndex)); + Map jsonProps = schema.getJsonProps(); + schema = Schema.createUnion(types); + // copy any properties over + for (String property : jsonProps.keySet()) { + schema.addProp(property, jsonProps.get(property)); + } + } + return schema; + } } diff --git a/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/interop/annotations/AvroDefaultTest.java b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/interop/annotations/AvroDefaultTest.java new file mode 100644 index 000000000..bc2e3c56b --- /dev/null +++ b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/interop/annotations/AvroDefaultTest.java @@ -0,0 +1,44 @@ +package com.fasterxml.jackson.dataformat.avro.interop.annotations; + +import com.fasterxml.jackson.dataformat.avro.interop.ApacheAvroInteropUtil; + +import org.apache.avro.Schema; +import org.apache.avro.reflect.AvroDefault; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AvroDefaultTest { + static class RecordWithDefaults { + @AvroDefault("\"Test Field\"") + public String stringField; + @AvroDefault("1234") + public Integer intField; + @AvroDefault("true") + public Integer booleanField; + } + + @Test + public void testUnionBooleanDefault() { + Schema apacheSchema = ApacheAvroInteropUtil.getApacheSchema(RecordWithDefaults.class); + Schema jacksonSchema = ApacheAvroInteropUtil.getJacksonSchema(RecordWithDefaults.class); + // + assertThat(jacksonSchema.getField("booleanField").defaultValue()).isEqualTo(apacheSchema.getField("booleanField").defaultValue()); + } + + @Test + public void testUnionIntegerDefault() { + Schema apacheSchema = ApacheAvroInteropUtil.getApacheSchema(RecordWithDefaults.class); + Schema jacksonSchema = ApacheAvroInteropUtil.getJacksonSchema(RecordWithDefaults.class); + // + assertThat(jacksonSchema.getField("intField").defaultValue()).isEqualTo(apacheSchema.getField("intField").defaultValue()); + } + + @Test + public void testUnionStringDefault() { + Schema apacheSchema = ApacheAvroInteropUtil.getApacheSchema(RecordWithDefaults.class); + Schema jacksonSchema = ApacheAvroInteropUtil.getJacksonSchema(RecordWithDefaults.class); + // + assertThat(jacksonSchema.getField("stringField").defaultValue()).isEqualTo(apacheSchema.getField("stringField").defaultValue()); + } +}