Skip to content

Commit 2db08cc

Browse files
author
Pritam Kumar Mishra
committed
[FLINK-40262][format/avro] Support matching Avro record fields by name
RowDataToAvroConverters and AvroToRowDataConverters pair a RowType field with an Avro record field by ordinal position. That is correct when the Avro schema is derived from the row type by AvroSchemaConverter, since both sides then agree on field order, but not when the schema is supplied independently - a schema registry subject, 'avro-confluent.schema', or the AvroRowData{,De}serialization Schema constructors that take a nested schema. There, column i and Avro field i need not be the same field, and the converters silently write and read the wrong one. Introduce an explicit strategy, FieldMatching, and thread it through both converters: - INDEX keeps today's behaviour and stays the default, byte for byte. - NAME pairs fields by name. Names are compared exactly first, then against Avro field aliases, and finally ignoring case, each stage running to completion before the next so a fuzzy match can never claim a field that some other column matches exactly. AvroFieldMatcher does the resolution and refuses anything that is ambiguous or that would quietly lose data: a column matching several Avro fields, two columns matching the same one, a column with no Avro counterpart when writing, a NOT NULL column with no counterpart when reading, or an Avro field that nothing writes to and that is neither nullable nor defaulted. Avro fields that no column maps to but that declare a default are written with that default; Avro fields no column reads are ignored, which is ordinary projection. Resolution is done once per (row type, schema) pair and memoized, so NAME costs an array lookup per field per record - no hashing, no lowercasing, no allocation on the hot path. The memo is transient and rebuilt after the converter is shipped to a task. This commit only makes the converters capable of it. Exposing the strategy to SQL as an 'avro-confluent.field-matching' option, and relaxing the order-sensitive schema check in RegistryAvroFormatFactory that rejects such a schema outright today, is left to a follow-up so the two can be reviewed independently.
1 parent bc30598 commit 2db08cc

7 files changed

Lines changed: 1455 additions & 49 deletions

File tree

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package org.apache.flink.formats.avro;
20+
21+
import org.apache.flink.annotation.Internal;
22+
import org.apache.flink.table.types.logical.RowType;
23+
24+
import org.apache.avro.AvroRuntimeException;
25+
import org.apache.avro.Schema;
26+
import org.apache.avro.generic.GenericData;
27+
import org.apache.avro.generic.IndexedRecord;
28+
29+
import java.util.ArrayList;
30+
import java.util.Arrays;
31+
import java.util.HashMap;
32+
import java.util.List;
33+
import java.util.Locale;
34+
import java.util.Map;
35+
import java.util.stream.Collectors;
36+
37+
/**
38+
* Resolves {@link FieldMatching#NAME} pairings between the fields of a Flink {@link RowType} and
39+
* the fields of an Avro record {@link Schema}.
40+
*
41+
* <p>Matching is attempted in three stages, so that the more surprising rules can only ever apply
42+
* to fields the stricter rules left over:
43+
*
44+
* <ol>
45+
* <li>an exact, case-sensitive comparison of the Avro field name;
46+
* <li>an exact, case-sensitive comparison against the Avro field <i>aliases</i>;
47+
* <li>a case-insensitive ({@link Locale#ROOT}) comparison against names and aliases.
48+
* </ol>
49+
*
50+
* <p>Anything that cannot be resolved unambiguously, and anything that would silently lose or
51+
* fabricate data, is rejected with a message that names the offending field. In particular a column
52+
* that has no Avro counterpart is an error when writing (the column would be dropped) and an error
53+
* when reading if the column is {@code NOT NULL} (the column could only be read as {@code NULL}).
54+
* Avro fields that no column maps to are tolerated: when writing they must be nullable or declare a
55+
* default, when reading they are simply ignored.
56+
*
57+
* <p>Resolution is deliberately expensive and done once per (row type, schema) pair; the resulting
58+
* {@link Plan} reduces the per-record cost to an array lookup.
59+
*/
60+
@Internal
61+
public final class AvroFieldMatcher {
62+
63+
/** Returned by {@link Plan#avroPositionOf(int)} for a column with no Avro counterpart. */
64+
public static final int UNMATCHED = -1;
65+
66+
/** Internal marker for a lookup key that would match more than one Avro field. */
67+
private static final int AMBIGUOUS = -2;
68+
69+
private AvroFieldMatcher() {}
70+
71+
/**
72+
* Resolves the pairing used to convert a {@link RowType} into a record of the given schema.
73+
*
74+
* @throws IllegalArgumentException if the pairing is ambiguous, or if it would drop a column,
75+
* or if it would leave an Avro field that is neither nullable nor defaulted unwritten.
76+
*/
77+
public static Plan forSerialization(RowType rowType, Schema recordSchema) {
78+
return resolve(rowType, recordSchema, true);
79+
}
80+
81+
/**
82+
* Resolves the pairing used to convert a record of the given schema into a {@link RowType}.
83+
*
84+
* @throws IllegalArgumentException if the pairing is ambiguous, or if a {@code NOT NULL} column
85+
* has no Avro counterpart.
86+
*/
87+
public static Plan forDeserialization(RowType rowType, Schema recordSchema) {
88+
return resolve(rowType, recordSchema, false);
89+
}
90+
91+
private static Plan resolve(RowType rowType, Schema recordSchema, boolean forSerialization) {
92+
if (recordSchema.getType() != Schema.Type.RECORD) {
93+
throw new IllegalArgumentException(
94+
String.format(
95+
"Matching fields by name requires an Avro RECORD schema for row type %s, but got: %s",
96+
rowType, recordSchema));
97+
}
98+
99+
final List<Schema.Field> avroFields = recordSchema.getFields();
100+
final List<String> rowFieldNames = rowType.getFieldNames();
101+
final int arity = rowFieldNames.size();
102+
103+
final int[] rowToAvroPos = new int[arity];
104+
final int[] avroPosToRowField = new int[avroFields.size()];
105+
Arrays.fill(rowToAvroPos, UNMATCHED);
106+
Arrays.fill(avroPosToRowField, UNMATCHED);
107+
108+
// Stage 1: exact names. Row field names are unique, so this stage cannot conflict with
109+
// itself, and running it to completion first guarantees that a fuzzy match can never
110+
// claim an Avro field that some other column matches exactly.
111+
for (int i = 0; i < arity; i++) {
112+
final Schema.Field avroField = recordSchema.getField(rowFieldNames.get(i));
113+
if (avroField != null) {
114+
rowToAvroPos[i] = avroField.pos();
115+
avroPosToRowField[avroField.pos()] = i;
116+
}
117+
}
118+
119+
// Stages 2 and 3: aliases, then a case-insensitive comparison.
120+
final Map<String, Integer> byAlias = new HashMap<>();
121+
final Map<String, Integer> byLowerCase = new HashMap<>();
122+
for (Schema.Field avroField : avroFields) {
123+
index(byLowerCase, avroField.name(), avroField.pos());
124+
for (String alias : avroField.aliases()) {
125+
byAlias.merge(alias, avroField.pos(), AvroFieldMatcher::mergePositions);
126+
index(byLowerCase, alias, avroField.pos());
127+
}
128+
}
129+
130+
for (int i = 0; i < arity; i++) {
131+
if (rowToAvroPos[i] != UNMATCHED) {
132+
continue;
133+
}
134+
final String rowFieldName = rowFieldNames.get(i);
135+
136+
String rule = "an Avro field alias";
137+
int candidate = byAlias.getOrDefault(rowFieldName, UNMATCHED);
138+
if (candidate == UNMATCHED) {
139+
rule = "a case-insensitive comparison";
140+
candidate =
141+
byLowerCase.getOrDefault(rowFieldName.toLowerCase(Locale.ROOT), UNMATCHED);
142+
}
143+
144+
if (candidate == UNMATCHED) {
145+
continue;
146+
}
147+
if (candidate == AMBIGUOUS) {
148+
throw new IllegalArgumentException(
149+
String.format(
150+
"Column '%s' matches more than one field of the Avro record '%s' by %s. "
151+
+ "Rename the column so that it matches exactly one Avro field "
152+
+ "(fields: %s).",
153+
rowFieldName,
154+
recordSchema.getFullName(),
155+
rule,
156+
fieldNames(avroFields)));
157+
}
158+
final int competitor = avroPosToRowField[candidate];
159+
if (competitor != UNMATCHED) {
160+
throw new IllegalArgumentException(
161+
String.format(
162+
"Columns '%s' and '%s' both match field '%s' of the Avro record '%s'. "
163+
+ "Rename one of the columns so that every Avro field is claimed "
164+
+ "at most once.",
165+
rowFieldNames.get(competitor),
166+
rowFieldName,
167+
avroFields.get(candidate).name(),
168+
recordSchema.getFullName()));
169+
}
170+
171+
rowToAvroPos[i] = candidate;
172+
avroPosToRowField[candidate] = i;
173+
}
174+
175+
return forSerialization
176+
? serializationPlan(recordSchema, rowToAvroPos, avroPosToRowField, rowFieldNames)
177+
: deserializationPlan(rowType, recordSchema, rowToAvroPos, rowFieldNames);
178+
}
179+
180+
private static Plan serializationPlan(
181+
Schema recordSchema,
182+
int[] rowToAvroPos,
183+
int[] avroPosToRowField,
184+
List<String> rowFieldNames) {
185+
final List<Schema.Field> avroFields = recordSchema.getFields();
186+
187+
// A column with no Avro counterpart would be dropped without a trace.
188+
for (int i = 0; i < rowToAvroPos.length; i++) {
189+
if (rowToAvroPos[i] == UNMATCHED) {
190+
throw new IllegalArgumentException(
191+
String.format(
192+
"Column '%s' cannot be written: the Avro record '%s' has no field "
193+
+ "matching that name (fields: %s). Add the field to the Avro "
194+
+ "schema, or project the column away before writing.",
195+
rowFieldNames.get(i),
196+
recordSchema.getFullName(),
197+
fieldNames(avroFields)));
198+
}
199+
}
200+
201+
// Avro fields that nothing writes to have to be either nullable or defaulted, otherwise
202+
// the record cannot be encoded at all and Avro reports it as an opaque
203+
// NullPointerException at the first record.
204+
final List<Integer> defaultedPositions = new ArrayList<>(0);
205+
final List<Object> defaultValues = new ArrayList<>(0);
206+
for (Schema.Field avroField : avroFields) {
207+
if (avroPosToRowField[avroField.pos()] != UNMATCHED) {
208+
continue;
209+
}
210+
if (avroField.hasDefaultValue()) {
211+
final Object defaultValue = materializeDefault(recordSchema, avroField);
212+
// A null default needs no action: a fresh record is null everywhere.
213+
if (defaultValue != null) {
214+
defaultedPositions.add(avroField.pos());
215+
defaultValues.add(defaultValue);
216+
}
217+
} else if (!isNullable(avroField.schema())) {
218+
throw new IllegalArgumentException(
219+
String.format(
220+
"Field '%s' of the Avro record '%s' is neither nullable nor does it "
221+
+ "declare a default value, but no column matches it "
222+
+ "(columns: %s). Add a matching column, or make the Avro field "
223+
+ "nullable, or give it a default value.",
224+
avroField.name(),
225+
recordSchema.getFullName(),
226+
String.join(", ", rowFieldNames)));
227+
}
228+
}
229+
230+
return new Plan(
231+
recordSchema,
232+
rowToAvroPos,
233+
defaultedPositions.stream().mapToInt(Integer::intValue).toArray(),
234+
defaultValues.toArray());
235+
}
236+
237+
private static Plan deserializationPlan(
238+
RowType rowType, Schema recordSchema, int[] rowToAvroPos, List<String> rowFieldNames) {
239+
// A NOT NULL column with no Avro counterpart could only ever be read as null, which
240+
// silently violates the contract the rest of the plan relies on.
241+
for (int i = 0; i < rowToAvroPos.length; i++) {
242+
if (rowToAvroPos[i] == UNMATCHED && !rowType.getTypeAt(i).isNullable()) {
243+
throw new IllegalArgumentException(
244+
String.format(
245+
"Column '%s' is declared NOT NULL, but the Avro record '%s' has no "
246+
+ "field matching that name (fields: %s), so it could only be "
247+
+ "read as NULL. Add the field to the Avro schema, or make the "
248+
+ "column nullable.",
249+
rowFieldNames.get(i),
250+
recordSchema.getFullName(),
251+
fieldNames(recordSchema.getFields())));
252+
}
253+
}
254+
return new Plan(recordSchema, rowToAvroPos, new int[0], new Object[0]);
255+
}
256+
257+
private static void index(Map<String, Integer> lookup, String name, int position) {
258+
lookup.merge(name.toLowerCase(Locale.ROOT), position, AvroFieldMatcher::mergePositions);
259+
}
260+
261+
private static int mergePositions(int existing, int added) {
262+
return existing == added ? existing : AMBIGUOUS;
263+
}
264+
265+
private static Object materializeDefault(Schema recordSchema, Schema.Field avroField) {
266+
final GenericData genericData = GenericData.get();
267+
try {
268+
// Copy the value: GenericData caches one shared instance of every default, and for
269+
// records, arrays and maps that instance would otherwise be aliased by every record
270+
// this converter produces.
271+
return genericData.deepCopy(avroField.schema(), genericData.getDefaultValue(avroField));
272+
} catch (AvroRuntimeException e) {
273+
throw new IllegalArgumentException(
274+
String.format(
275+
"Cannot use the default value of field '%s' of the Avro record '%s', "
276+
+ "which is required because no column matches that field.",
277+
avroField.name(), recordSchema.getFullName()),
278+
e);
279+
}
280+
}
281+
282+
private static boolean isNullable(Schema schema) {
283+
if (schema.getType() == Schema.Type.NULL) {
284+
return true;
285+
}
286+
return schema.getType() == Schema.Type.UNION
287+
&& schema.getTypes().stream().anyMatch(t -> t.getType() == Schema.Type.NULL);
288+
}
289+
290+
private static String fieldNames(List<Schema.Field> avroFields) {
291+
return avroFields.stream().map(Schema.Field::name).collect(Collectors.joining(", "));
292+
}
293+
294+
/**
295+
* An immutable, resolved pairing between a {@link RowType} and one Avro record {@link Schema}.
296+
*
297+
* <p>Every field is {@code final} and no reachable state is mutated after construction, so an
298+
* instance may be published through a plain, non-volatile field: the JMM guarantees that a
299+
* thread which observes the reference also observes the arrays it was built with.
300+
*/
301+
public static final class Plan {
302+
303+
private final Schema recordSchema;
304+
private final int[] rowToAvroPos;
305+
private final int[] defaultedAvroPos;
306+
private final Object[] defaultValues;
307+
308+
private Plan(
309+
Schema recordSchema,
310+
int[] rowToAvroPos,
311+
int[] defaultedAvroPos,
312+
Object[] defaultValues) {
313+
this.recordSchema = recordSchema;
314+
this.rowToAvroPos = rowToAvroPos;
315+
this.defaultedAvroPos = defaultedAvroPos;
316+
this.defaultValues = defaultValues;
317+
}
318+
319+
/**
320+
* Whether this plan was resolved against exactly the given schema instance.
321+
*
322+
* <p>Compares by identity on purpose: it is a cache guard, and Avro's {@code equals} walks
323+
* the whole schema. A miss only costs a re-resolution, and in practice the same instance is
324+
* handed to a converter for its entire lifetime.
325+
*/
326+
public boolean appliesTo(Schema schema) {
327+
return recordSchema == schema;
328+
}
329+
330+
/**
331+
* The position of the Avro field paired with the given row field, or {@link #UNMATCHED} if
332+
* the row field has no counterpart.
333+
*/
334+
public int avroPositionOf(int rowFieldIndex) {
335+
return rowToAvroPos[rowFieldIndex];
336+
}
337+
338+
/**
339+
* Writes the declared default of every Avro field that no row field maps to. Must be called
340+
* once per freshly created record; usually a no-op.
341+
*/
342+
public void fillDefaults(IndexedRecord record) {
343+
for (int i = 0; i < defaultedAvroPos.length; i++) {
344+
record.put(defaultedAvroPos[i], defaultValues[i]);
345+
}
346+
}
347+
}
348+
}

0 commit comments

Comments
 (0)