-
Notifications
You must be signed in to change notification settings - Fork 15.4k
[WIP] KAFKA-7157: Connect TimestampConverter SMT doesn't handle null values #6446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,6 +85,11 @@ public abstract class TimestampConverter<R extends ConnectRecord<R>> implements | |
|
|
||
| private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); | ||
|
|
||
| public static final Schema OPTIONAL_DATE_SCHEMA = org.apache.kafka.connect.data.Date.builder().optional().schema(); | ||
| public static final Schema OPTIONAL_TIMESTAMP_SCHEMA = Timestamp.builder().optional().schema(); | ||
| public static final Schema OPTIONAL_TIME_SCHEMA = Time.builder().optional().schema(); | ||
|
|
||
|
|
||
| private interface TimestampTranslator { | ||
| /** | ||
| * Convert from the type-specific format to the universal java.util.Date format | ||
|
|
@@ -94,7 +99,7 @@ private interface TimestampTranslator { | |
| /** | ||
| * Get the schema for this format. | ||
| */ | ||
| Schema typeSchema(); | ||
| Schema typeSchema(boolean isOptional); | ||
|
|
||
| /** | ||
| * Convert from the universal java.util.Date format to the type-specific format | ||
|
|
@@ -118,8 +123,8 @@ public Date toRaw(Config config, Object orig) { | |
| } | ||
|
|
||
| @Override | ||
| public Schema typeSchema() { | ||
| return Schema.STRING_SCHEMA; | ||
| public Schema typeSchema(boolean isOptional) { | ||
| return isOptional ? Schema.OPTIONAL_STRING_SCHEMA : Schema.STRING_SCHEMA; | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -139,8 +144,8 @@ public Date toRaw(Config config, Object orig) { | |
| } | ||
|
|
||
| @Override | ||
| public Schema typeSchema() { | ||
| return Schema.INT64_SCHEMA; | ||
| public Schema typeSchema(boolean isOptional) { | ||
| return isOptional ? Schema.OPTIONAL_INT64_SCHEMA : Schema.INT64_SCHEMA; | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -159,8 +164,8 @@ public Date toRaw(Config config, Object orig) { | |
| } | ||
|
|
||
| @Override | ||
| public Schema typeSchema() { | ||
| return org.apache.kafka.connect.data.Date.SCHEMA; | ||
| public Schema typeSchema(boolean isOptional) { | ||
| return isOptional ? OPTIONAL_DATE_SCHEMA : org.apache.kafka.connect.data.Date.SCHEMA; | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -185,8 +190,8 @@ public Date toRaw(Config config, Object orig) { | |
| } | ||
|
|
||
| @Override | ||
| public Schema typeSchema() { | ||
| return Time.SCHEMA; | ||
| public Schema typeSchema(boolean isOptional) { | ||
| return isOptional ? OPTIONAL_TIME_SCHEMA : Time.SCHEMA; | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -212,8 +217,8 @@ public Date toRaw(Config config, Object orig) { | |
| } | ||
|
|
||
| @Override | ||
| public Schema typeSchema() { | ||
| return Timestamp.SCHEMA; | ||
| public Schema typeSchema(boolean isOptional) { | ||
| return isOptional ? OPTIONAL_TIMESTAMP_SCHEMA : Timestamp.SCHEMA; | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -245,7 +250,7 @@ public void configure(Map<String, ?> configs) { | |
| final String field = simpleConfig.getString(FIELD_CONFIG); | ||
| final String type = simpleConfig.getString(TARGET_TYPE_CONFIG); | ||
| String formatPattern = simpleConfig.getString(FORMAT_CONFIG); | ||
| schemaUpdateCache = new SynchronizedCache<>(new LRUCache<Schema, Schema>(16)); | ||
| schemaUpdateCache = new SynchronizedCache<>(new LRUCache<>(16)); | ||
|
|
||
| if (!VALID_TYPES.contains(type)) { | ||
| throw new ConfigException("Unknown timestamp type in TimestampConverter: " + type + ". Valid values are " | ||
|
|
@@ -326,38 +331,47 @@ protected R newRecord(R record, Schema updatedSchema, Object updatedValue) { | |
| protected abstract R newRecord(R record, Schema updatedSchema, Object updatedValue); | ||
|
|
||
| private R applyWithSchema(R record) { | ||
| final Schema schema = operatingSchema(record); | ||
| final Schema originalSchema = operatingSchema(record); | ||
| final Object rawValue = operatingValue(record); | ||
| // Entire value is timestamp | ||
| if (config.field.isEmpty()) { | ||
| Object value = operatingValue(record); | ||
| // New schema is determined by the requested target timestamp type | ||
| Schema updatedSchema = TRANSLATORS.get(config.type).typeSchema(); | ||
| return newRecord(record, updatedSchema, convertTimestamp(value, timestampTypeFromSchema(schema))); | ||
| } else { | ||
| final Struct value = requireStruct(operatingValue(record), PURPOSE); | ||
| Schema updatedSchema = schemaUpdateCache.get(value.schema()); | ||
| if (updatedSchema == null) { | ||
| SchemaBuilder builder = SchemaUtil.copySchemaBasics(schema, SchemaBuilder.struct()); | ||
| for (Field field : schema.fields()) { | ||
| if (field.name().equals(config.field)) { | ||
| builder.field(field.name(), TRANSLATORS.get(config.type).typeSchema()); | ||
| } else { | ||
| builder.field(field.name(), field.schema()); | ||
| } | ||
| } | ||
| if (schema.isOptional()) | ||
| builder.optional(); | ||
| if (schema.defaultValue() != null) { | ||
| Struct updatedDefaultValue = applyValueWithSchema((Struct) schema.defaultValue(), builder); | ||
| builder.defaultValue(updatedDefaultValue); | ||
| } | ||
| Schema updatedSchema = TRANSLATORS.get(config.type).typeSchema(originalSchema.isOptional()); | ||
| return newRecord(record, updatedSchema, convertTimestamp(rawValue, timestampTypeFromSchema(originalSchema))); | ||
| } | ||
| // Value is Struct, only its single field should be converted | ||
| if (rawValue == null) { | ||
| Schema updatedSchema = updateSchema(originalSchema); | ||
| schemaUpdateCache.put(originalSchema, updatedSchema); | ||
| return newRecord(record, updatedSchema, null); | ||
| } | ||
|
|
||
| updatedSchema = builder.build(); | ||
| schemaUpdateCache.put(schema, updatedSchema); | ||
| } | ||
| final Struct struct = requireStruct(rawValue, PURPOSE); | ||
| Schema updatedSchema = schemaUpdateCache.get(struct.schema()); | ||
| if (updatedSchema == null) { | ||
| updatedSchema = updateSchema(originalSchema); | ||
| schemaUpdateCache.put(originalSchema, updatedSchema); | ||
| } | ||
| Struct updatedValue = applyValueWithSchema(struct, updatedSchema); | ||
| return newRecord(record, updatedSchema, updatedValue); | ||
| } | ||
|
|
||
| Struct updatedValue = applyValueWithSchema(value, updatedSchema); | ||
| return newRecord(record, updatedSchema, updatedValue); | ||
| private Schema updateSchema(Schema originalSchema) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, if we're only going to call this from one location (see previous comment), maybe it's better to not pull this logic out to minimize changes. |
||
| SchemaBuilder builder = SchemaUtil.copySchemaBasics(originalSchema, SchemaBuilder.struct()); | ||
| for (Field field : originalSchema.fields()) { | ||
| if (field.name().equals(config.field)) { | ||
| builder.field(field.name(), TRANSLATORS.get(config.type).typeSchema(field.schema().isOptional())); | ||
| } else { | ||
| builder.field(field.name(), field.schema()); | ||
| } | ||
| } | ||
| if (originalSchema.isOptional()) | ||
| builder.optional(); | ||
| if (originalSchema.defaultValue() != null) { | ||
| Struct updatedDefaultValue = applyValueWithSchema((Struct) originalSchema.defaultValue(), builder); | ||
| builder.defaultValue(updatedDefaultValue); | ||
| } | ||
| return builder.build(); | ||
| } | ||
|
|
||
| private Struct applyValueWithSchema(Struct value, Schema updatedSchema) { | ||
|
|
@@ -375,15 +389,15 @@ private Struct applyValueWithSchema(Struct value, Schema updatedSchema) { | |
| } | ||
|
|
||
| private R applySchemaless(R record) { | ||
| if (config.field.isEmpty()) { | ||
| Object value = operatingValue(record); | ||
| return newRecord(record, null, convertTimestamp(value)); | ||
| } else { | ||
| final Map<String, Object> value = requireMap(operatingValue(record), PURPOSE); | ||
| final HashMap<String, Object> updatedValue = new HashMap<>(value); | ||
| updatedValue.put(config.field, convertTimestamp(value.get(config.field))); | ||
| return newRecord(record, null, updatedValue); | ||
| Object rawValue = operatingValue(record); | ||
| if (rawValue == null || config.field.isEmpty()) { | ||
| Object value = convertTimestamp(rawValue); | ||
| return newRecord(record, null, value); | ||
| } | ||
| final Map<String, Object> value = requireMap(rawValue, PURPOSE); | ||
| final HashMap<String, Object> updatedValue = new HashMap<>(value); | ||
| updatedValue.put(config.field, convertTimestamp(value.get(config.field))); | ||
| return newRecord(record, null, updatedValue); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I understand that the if block always returns, and so an |
||
| } | ||
|
|
||
| /** | ||
|
|
@@ -424,11 +438,14 @@ private String inferTimestampType(Object timestamp) { | |
|
|
||
| /** | ||
| * Convert the given timestamp to the target timestamp format. | ||
| * @param timestamp the input timestamp | ||
| * @param timestamp the input timestamp, may be null | ||
| * @param timestampFormat the format of the timestamp, or null if the format should be inferred | ||
| * @return the converted timestamp | ||
| */ | ||
| private Object convertTimestamp(Object timestamp, String timestampFormat) { | ||
| if (timestamp == null) { | ||
| return null; | ||
| } | ||
| if (timestampFormat == null) { | ||
| timestampFormat = inferTimestampType(timestamp); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not using a cached schema, which means that a new schema has to be built for every record that uses some original schema, A, but that also has a null timestamp field. Wouldn't it make sense to cache this updated schema? And, since the updates schema is not a function of the record, we should be able to look for the updated schema in the cache or update the schema and cache it before dealing with the raw value.
This might also mean that there are fewer lines changed, especially if we keep the
elseblock and previous indentation (as mentioned before, even though strictly speaking theelseis unnecessary), which might help us ensure that the behavior doesn't change for the non-null timestamps.