Skip to content

Commit 6180cd6

Browse files
committed
feat: add StructArrayExplode SMT for exploding arrays of structs into separate 1D double[] columns
Unlike OrderBookToArray which produces a single 2D array column, StructArrayExplode creates one ARRAY(FLOAT64) column per struct field. This supports use cases like ingesting Protobuf repeated fields where each field needs its own column.
1 parent c585711 commit 6180cd6

3 files changed

Lines changed: 1617 additions & 0 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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

Comments
 (0)