|
| 1 | +package io.questdb.kafka; |
| 2 | + |
| 3 | +import org.apache.kafka.common.config.ConfigDef; |
| 4 | +import org.apache.kafka.common.config.ConfigException; |
| 5 | +import org.apache.kafka.connect.connector.ConnectRecord; |
| 6 | +import org.apache.kafka.connect.data.Field; |
| 7 | +import org.apache.kafka.connect.data.Schema; |
| 8 | +import org.apache.kafka.connect.data.SchemaBuilder; |
| 9 | +import org.apache.kafka.connect.data.Struct; |
| 10 | +import org.apache.kafka.connect.errors.ConnectException; |
| 11 | +import org.apache.kafka.connect.transforms.Transformation; |
| 12 | + |
| 13 | +import java.util.ArrayList; |
| 14 | +import java.util.HashMap; |
| 15 | +import java.util.HashSet; |
| 16 | +import java.util.IdentityHashMap; |
| 17 | +import java.util.LinkedHashMap; |
| 18 | +import java.util.List; |
| 19 | +import java.util.Map; |
| 20 | +import java.util.Set; |
| 21 | + |
| 22 | +public abstract class StructArrayExplode<R extends ConnectRecord<R>> implements Transformation<R> { |
| 23 | + public static final String MAPPINGS_CONFIG = "mappings"; |
| 24 | + |
| 25 | + private static final ConfigDef CONFIG_DEF = new ConfigDef() |
| 26 | + .define(MAPPINGS_CONFIG, ConfigDef.Type.STRING, ConfigDef.NO_DEFAULT_VALUE, |
| 27 | + ConfigDef.Importance.HIGH, |
| 28 | + "Semicolon-separated mappings: sourceField:targetCol1,targetCol2:structField1,structField2"); |
| 29 | + |
| 30 | + private List<SourceMapping> mappings; |
| 31 | + private Map<String, SourceMapping> sourceFieldMap; |
| 32 | + private Set<String> targetFieldNames; |
| 33 | + private final IdentityHashMap<Schema, Schema> schemaCache = new IdentityHashMap<>(); |
| 34 | + |
| 35 | + protected abstract Schema operatingSchema(R record); |
| 36 | + protected abstract Object operatingValue(R record); |
| 37 | + protected abstract R newRecord(R record, Schema updatedSchema, Object updatedValue); |
| 38 | + |
| 39 | + @Override |
| 40 | + public void configure(Map<String, ?> configs) { |
| 41 | + String raw = (String) configs.get(MAPPINGS_CONFIG); |
| 42 | + if (raw == null || raw.trim().isEmpty()) { |
| 43 | + throw new ConfigException(MAPPINGS_CONFIG, raw, "At least one mapping is required"); |
| 44 | + } |
| 45 | + mappings = new ArrayList<>(); |
| 46 | + sourceFieldMap = new HashMap<>(); |
| 47 | + targetFieldNames = new HashSet<>(); |
| 48 | + for (String entry : raw.split(";")) { |
| 49 | + entry = entry.trim(); |
| 50 | + if (entry.isEmpty()) { |
| 51 | + continue; |
| 52 | + } |
| 53 | + String[] parts = entry.split(":"); |
| 54 | + if (parts.length != 3) { |
| 55 | + throw new IllegalArgumentException("Invalid mapping entry: '" + entry |
| 56 | + + "'. Expected format: sourceField:targetCol1,targetCol2:structField1,structField2"); |
| 57 | + } |
| 58 | + String sourceField = parts[0].trim(); |
| 59 | + String[] targetColumns = parts[1].trim().split(","); |
| 60 | + String[] structFields = parts[2].trim().split(","); |
| 61 | + for (int i = 0; i < targetColumns.length; i++) { |
| 62 | + targetColumns[i] = targetColumns[i].trim(); |
| 63 | + } |
| 64 | + for (int i = 0; i < structFields.length; i++) { |
| 65 | + structFields[i] = structFields[i].trim(); |
| 66 | + } |
| 67 | + if (targetColumns.length != structFields.length) { |
| 68 | + throw new IllegalArgumentException("Invalid mapping entry: '" + entry |
| 69 | + + "'. Number of target columns (" + targetColumns.length |
| 70 | + + ") must match number of struct fields (" + structFields.length + ")"); |
| 71 | + } |
| 72 | + List<ColumnMapping> columnMappings = new ArrayList<>(targetColumns.length); |
| 73 | + for (int i = 0; i < targetColumns.length; i++) { |
| 74 | + if (!targetFieldNames.add(targetColumns[i])) { |
| 75 | + throw new IllegalArgumentException("Duplicate target column name: '" + targetColumns[i] + "'"); |
| 76 | + } |
| 77 | + columnMappings.add(new ColumnMapping(structFields[i], targetColumns[i])); |
| 78 | + } |
| 79 | + SourceMapping mapping = new SourceMapping(sourceField, columnMappings); |
| 80 | + mappings.add(mapping); |
| 81 | + if (sourceFieldMap.put(sourceField, mapping) != null) { |
| 82 | + throw new IllegalArgumentException("Duplicate source field: '" + sourceField |
| 83 | + + "'. Combine all target columns into a single mapping entry"); |
| 84 | + } |
| 85 | + } |
| 86 | + if (mappings.isEmpty()) { |
| 87 | + throw new ConfigException(MAPPINGS_CONFIG, raw, "At least one mapping is required"); |
| 88 | + } |
| 89 | + for (SourceMapping mapping : mappings) { |
| 90 | + for (ColumnMapping cm : mapping.columnMappings) { |
| 91 | + SourceMapping conflicting = sourceFieldMap.get(cm.targetColumn); |
| 92 | + if (conflicting != null && conflicting != mapping) { |
| 93 | + throw new IllegalArgumentException("Target column '" + cm.targetColumn |
| 94 | + + "' conflicts with source field of another mapping"); |
| 95 | + } |
| 96 | + } |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + @Override |
| 101 | + public R apply(R record) { |
| 102 | + Object value = operatingValue(record); |
| 103 | + if (value == null) { |
| 104 | + return record; |
| 105 | + } |
| 106 | + Schema schema = operatingSchema(record); |
| 107 | + if (schema != null) { |
| 108 | + return applyWithSchema(record, schema, (Struct) value); |
| 109 | + } else { |
| 110 | + return applySchemaless(record, value); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + private R applyWithSchema(R record, Schema originalSchema, Struct originalStruct) { |
| 115 | + Schema newSchema = getOrBuildSchema(originalSchema); |
| 116 | + Struct newStruct = new Struct(newSchema); |
| 117 | + |
| 118 | + // Copy non-mapped fields (skip source fields and target fields that will be replaced) |
| 119 | + for (Field field : originalSchema.fields()) { |
| 120 | + if (!sourceFieldMap.containsKey(field.name()) && !targetFieldNames.contains(field.name())) { |
| 121 | + newStruct.put(field.name(), originalStruct.get(field)); |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + // Explode mapped fields into separate 1D columns |
| 126 | + for (SourceMapping mapping : mappings) { |
| 127 | + if (originalSchema.field(mapping.sourceField) == null) { |
| 128 | + continue; |
| 129 | + } |
| 130 | + List<Struct> entries = originalStruct.getArray(mapping.sourceField); |
| 131 | + if (entries == null || entries.isEmpty()) { |
| 132 | + continue; |
| 133 | + } |
| 134 | + for (ColumnMapping cm : mapping.columnMappings) { |
| 135 | + List<Double> column = new ArrayList<>(entries.size()); |
| 136 | + for (Struct entry : entries) { |
| 137 | + Object val = entry.get(cm.structField); |
| 138 | + column.add(toDouble(val, cm.structField, mapping.sourceField)); |
| 139 | + } |
| 140 | + newStruct.put(cm.targetColumn, column); |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + return newRecord(record, newSchema, newStruct); |
| 145 | + } |
| 146 | + |
| 147 | + private Schema getOrBuildSchema(Schema originalSchema) { |
| 148 | + Schema cached = schemaCache.get(originalSchema); |
| 149 | + if (cached != null) { |
| 150 | + return cached; |
| 151 | + } |
| 152 | + |
| 153 | + SchemaBuilder builder = SchemaBuilder.struct(); |
| 154 | + if (originalSchema.name() != null) { |
| 155 | + builder.name(originalSchema.name()); |
| 156 | + } |
| 157 | + if (originalSchema.version() != null) { |
| 158 | + builder.version(originalSchema.version()); |
| 159 | + } |
| 160 | + if (originalSchema.doc() != null) { |
| 161 | + builder.doc(originalSchema.doc()); |
| 162 | + } |
| 163 | + if (originalSchema.parameters() != null) { |
| 164 | + builder.parameters(originalSchema.parameters()); |
| 165 | + } |
| 166 | + if (originalSchema.defaultValue() != null) { |
| 167 | + builder.defaultValue(originalSchema.defaultValue()); |
| 168 | + } |
| 169 | + if (originalSchema.isOptional()) { |
| 170 | + builder.optional(); |
| 171 | + } |
| 172 | + |
| 173 | + for (Field field : originalSchema.fields()) { |
| 174 | + if (!sourceFieldMap.containsKey(field.name()) && !targetFieldNames.contains(field.name())) { |
| 175 | + builder.field(field.name(), field.schema()); |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + // Add target fields as optional ARRAY(FLOAT64) (1D), only if source field exists. |
| 180 | + // Target is always optional: null/empty source arrays are skipped at runtime |
| 181 | + // (QuestDB does not support zero-length arrays). |
| 182 | + Schema targetArraySchema = SchemaBuilder.array(Schema.FLOAT64_SCHEMA).optional().build(); |
| 183 | + for (SourceMapping mapping : mappings) { |
| 184 | + if (originalSchema.field(mapping.sourceField) != null) { |
| 185 | + for (ColumnMapping cm : mapping.columnMappings) { |
| 186 | + builder.field(cm.targetColumn, targetArraySchema); |
| 187 | + } |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + Schema newSchema = builder.build(); |
| 192 | + schemaCache.put(originalSchema, newSchema); |
| 193 | + return newSchema; |
| 194 | + } |
| 195 | + |
| 196 | + private static double toDouble(Object val, String structField, String sourceField) { |
| 197 | + if (val == null) { |
| 198 | + throw new ConnectException("Null value for field '" + structField + "' in source array '" + sourceField + "'"); |
| 199 | + } |
| 200 | + if (val instanceof Number) { |
| 201 | + return ((Number) val).doubleValue(); |
| 202 | + } |
| 203 | + if (val instanceof String) { |
| 204 | + try { |
| 205 | + return Double.parseDouble((String) val); |
| 206 | + } catch (NumberFormatException e) { |
| 207 | + throw new ConnectException("Cannot parse '" + val + "' as double for field '" |
| 208 | + + structField + "' in source array '" + sourceField + "'"); |
| 209 | + } |
| 210 | + } |
| 211 | + throw new ConnectException("Unsupported type " + val.getClass().getName() + " for field '" |
| 212 | + + structField + "' in source array '" + sourceField + "'"); |
| 213 | + } |
| 214 | + |
| 215 | + @SuppressWarnings("unchecked") |
| 216 | + private R applySchemaless(R record, Object value) { |
| 217 | + Map<String, Object> original = (Map<String, Object>) value; |
| 218 | + Map<String, Object> result = new LinkedHashMap<>(original); |
| 219 | + |
| 220 | + for (SourceMapping mapping : mappings) { |
| 221 | + List<Map<String, Object>> entries = (List<Map<String, Object>>) result.remove(mapping.sourceField); |
| 222 | + // Remove colliding target keys before the empty-check so pre-existing |
| 223 | + // scalar values never leak through when the source array is empty/null. |
| 224 | + for (ColumnMapping cm : mapping.columnMappings) { |
| 225 | + result.remove(cm.targetColumn); |
| 226 | + } |
| 227 | + if (entries == null || entries.isEmpty()) { |
| 228 | + continue; |
| 229 | + } |
| 230 | + for (ColumnMapping cm : mapping.columnMappings) { |
| 231 | + List<Double> column = new ArrayList<>(entries.size()); |
| 232 | + for (Map<String, Object> entry : entries) { |
| 233 | + Object val = entry.get(cm.structField); |
| 234 | + column.add(toDouble(val, cm.structField, mapping.sourceField)); |
| 235 | + } |
| 236 | + result.put(cm.targetColumn, column); |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + return newRecord(record, null, result); |
| 241 | + } |
| 242 | + |
| 243 | + @Override |
| 244 | + public ConfigDef config() { |
| 245 | + return CONFIG_DEF; |
| 246 | + } |
| 247 | + |
| 248 | + @Override |
| 249 | + public void close() { |
| 250 | + schemaCache.clear(); |
| 251 | + } |
| 252 | + |
| 253 | + private static final class ColumnMapping { |
| 254 | + final String structField; |
| 255 | + final String targetColumn; |
| 256 | + |
| 257 | + ColumnMapping(String structField, String targetColumn) { |
| 258 | + this.structField = structField; |
| 259 | + this.targetColumn = targetColumn; |
| 260 | + } |
| 261 | + } |
| 262 | + |
| 263 | + private static final class SourceMapping { |
| 264 | + final String sourceField; |
| 265 | + final List<ColumnMapping> columnMappings; |
| 266 | + |
| 267 | + SourceMapping(String sourceField, List<ColumnMapping> columnMappings) { |
| 268 | + this.sourceField = sourceField; |
| 269 | + this.columnMappings = columnMappings; |
| 270 | + } |
| 271 | + } |
| 272 | + |
| 273 | + public static class Key<R extends ConnectRecord<R>> extends StructArrayExplode<R> { |
| 274 | + @Override |
| 275 | + protected Schema operatingSchema(R record) { |
| 276 | + return record.keySchema(); |
| 277 | + } |
| 278 | + |
| 279 | + @Override |
| 280 | + protected Object operatingValue(R record) { |
| 281 | + return record.key(); |
| 282 | + } |
| 283 | + |
| 284 | + @Override |
| 285 | + protected R newRecord(R record, Schema updatedSchema, Object updatedValue) { |
| 286 | + return record.newRecord(record.topic(), record.kafkaPartition(), |
| 287 | + updatedSchema, updatedValue, |
| 288 | + record.valueSchema(), record.value(), |
| 289 | + record.timestamp()); |
| 290 | + } |
| 291 | + } |
| 292 | + |
| 293 | + public static class Value<R extends ConnectRecord<R>> extends StructArrayExplode<R> { |
| 294 | + @Override |
| 295 | + protected Schema operatingSchema(R record) { |
| 296 | + return record.valueSchema(); |
| 297 | + } |
| 298 | + |
| 299 | + @Override |
| 300 | + protected Object operatingValue(R record) { |
| 301 | + return record.value(); |
| 302 | + } |
| 303 | + |
| 304 | + @Override |
| 305 | + protected R newRecord(R record, Schema updatedSchema, Object updatedValue) { |
| 306 | + return record.newRecord(record.topic(), record.kafkaPartition(), |
| 307 | + record.keySchema(), record.key(), |
| 308 | + updatedSchema, updatedValue, |
| 309 | + record.timestamp()); |
| 310 | + } |
| 311 | + } |
| 312 | +} |
0 commit comments