|
| 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