|
| 1 | +package com.snowflake.kafka.connector; |
| 2 | + |
| 3 | +import static com.snowflake.kafka.connector.internal.TestUtils.assertTableColumnCount; |
| 4 | +import static com.snowflake.kafka.connector.internal.TestUtils.assertWithRetry; |
| 5 | + |
| 6 | +import com.snowflake.kafka.connector.internal.TestUtils; |
| 7 | +import io.confluent.connect.avro.AvroConverter; |
| 8 | +import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; |
| 9 | +import io.confluent.kafka.schemaregistry.testutil.MockSchemaRegistry; |
| 10 | +import io.confluent.kafka.serializers.KafkaAvroSerializer; |
| 11 | +import java.math.BigDecimal; |
| 12 | +import java.nio.ByteBuffer; |
| 13 | +import java.util.HashMap; |
| 14 | +import java.util.Map; |
| 15 | +import java.util.Properties; |
| 16 | +import org.apache.avro.Conversions; |
| 17 | +import org.apache.avro.LogicalTypes; |
| 18 | +import org.apache.avro.Schema; |
| 19 | +import org.apache.avro.generic.GenericData; |
| 20 | +import org.apache.avro.generic.GenericRecord; |
| 21 | +import org.apache.kafka.clients.producer.KafkaProducer; |
| 22 | +import org.apache.kafka.clients.producer.ProducerConfig; |
| 23 | +import org.apache.kafka.clients.producer.ProducerRecord; |
| 24 | +import org.apache.kafka.common.serialization.StringSerializer; |
| 25 | +import org.apache.kafka.connect.runtime.ConnectorConfig; |
| 26 | +import org.junit.jupiter.api.AfterEach; |
| 27 | +import org.junit.jupiter.api.BeforeEach; |
| 28 | +import org.junit.jupiter.api.Test; |
| 29 | + |
| 30 | +/** |
| 31 | + * Integration test for schema evolution using Avro with Schema Registry. Tests that the table is |
| 32 | + * updated with correct column types when records with different Avro schemas are sent from multiple |
| 33 | + * topics. |
| 34 | + */ |
| 35 | +class SchemaEvolutionAvroSrIT extends SchemaEvolutionBase { |
| 36 | + |
| 37 | + private static final String MOCK_SCHEMA_REGISTRY_URL = "mock://test-schema-registry"; |
| 38 | + |
| 39 | + private static final String PERFORMANCE_STRING = "PERFORMANCE_STRING"; |
| 40 | + private static final String PERFORMANCE_CHAR = "PERFORMANCE_CHAR"; |
| 41 | + private static final String RATING_INT = "RATING_INT"; |
| 42 | + private static final String RATING_DOUBLE = "RATING_DOUBLE"; |
| 43 | + private static final String APPROVAL = "APPROVAL"; |
| 44 | + private static final String TIME_MILLIS = "TIME_MILLIS"; |
| 45 | + private static final String TIMESTAMP_MILLIS = "TIMESTAMP_MILLIS"; |
| 46 | + private static final String DATE = "DATE"; |
| 47 | + private static final String DECIMAL = "DECIMAL"; |
| 48 | + private static final String SOME_FLOAT_NAN = "SOME_FLOAT_NAN"; |
| 49 | + private static final String RECORD_METADATA = "RECORD_METADATA"; |
| 50 | + |
| 51 | + private static final Map<String, String> EXPECTED_SCHEMA = new HashMap(); |
| 52 | + |
| 53 | + static { |
| 54 | + EXPECTED_SCHEMA.put(PERFORMANCE_STRING, "VARCHAR"); |
| 55 | + EXPECTED_SCHEMA.put(PERFORMANCE_CHAR, "VARCHAR"); |
| 56 | + EXPECTED_SCHEMA.put(RATING_INT, "NUMBER"); |
| 57 | + EXPECTED_SCHEMA.put( |
| 58 | + RATING_DOUBLE, "NUMBER"); // no floats anymore in server side SSV2 schema evo) |
| 59 | + EXPECTED_SCHEMA.put(APPROVAL, "BOOLEAN"); |
| 60 | + EXPECTED_SCHEMA.put( |
| 61 | + SOME_FLOAT_NAN, "VARCHAR"); // no floats anymore in server side SSV2 schema evo) |
| 62 | + EXPECTED_SCHEMA.put(TIME_MILLIS, "TIME"); |
| 63 | + EXPECTED_SCHEMA.put(TIMESTAMP_MILLIS, "VARCHAR"); |
| 64 | + EXPECTED_SCHEMA.put(DATE, "TIME"); |
| 65 | + EXPECTED_SCHEMA.put(DECIMAL, "NUMBER"); |
| 66 | + EXPECTED_SCHEMA.put(RECORD_METADATA, "VARIANT"); |
| 67 | + } |
| 68 | + |
| 69 | + private static final String VALUE_SCHEMA_0 = |
| 70 | + "{\"type\": \"record\",\"name\": \"value_schema_0\",\"fields\": [ {\"name\":" |
| 71 | + + " \"PERFORMANCE_CHAR\", \"type\": \"string\"}, {\"name\": \"PERFORMANCE_STRING\"," |
| 72 | + + " \"type\": \"string\"}," |
| 73 | + + " {\"name\":\"TIME_MILLIS\",\"type\":{\"type\":\"int\",\"logicalType\":\"time-millis\"}}," |
| 74 | + + "{\"name\":\"DATE\",\"type\":{\"type\":\"int\",\"logicalType\":\"date\"}},{\"name\":\"DECIMAL\",\"type\":{\"type\":\"bytes\",\"logicalType\":\"decimal\"," |
| 75 | + + " \"precision\":4, \"scale\":2}}," |
| 76 | + + "{\"name\":\"TIMESTAMP_MILLIS\",\"type\":{\"type\":\"long\",\"logicalType\":\"timestamp-millis\"}}," |
| 77 | + + " {\"name\": \"RATING_INT\", \"type\": \"int\"}]}"; |
| 78 | + |
| 79 | + private static final String VALUE_SCHEMA_1 = |
| 80 | + "{" |
| 81 | + + "\"type\": \"record\"," |
| 82 | + + "\"name\": \"value_schema_1\"," |
| 83 | + + "\"fields\": [" |
| 84 | + + " {\"name\": \"RATING_DOUBLE\", \"type\": \"float\"}," |
| 85 | + + " {\"name\": \"PERFORMANCE_STRING\", \"type\": \"string\"}," |
| 86 | + + " {\"name\": \"APPROVAL\", \"type\": \"boolean\"}," |
| 87 | + + " {\"name\": \"SOME_FLOAT_NAN\", \"type\": \"float\"}" |
| 88 | + + "]" |
| 89 | + + "}"; |
| 90 | + |
| 91 | + private static final String SCHEMA_REGISTRY_SCOPE = "test-schema-registry"; |
| 92 | + private static final int COL_NUM = 11; |
| 93 | + |
| 94 | + private KafkaProducer<String, Object> avroProducer; |
| 95 | + |
| 96 | + @BeforeEach |
| 97 | + void beforeEach() { |
| 98 | + avroProducer = createAvroProducer(); |
| 99 | + } |
| 100 | + |
| 101 | + @AfterEach |
| 102 | + void afterEach() { |
| 103 | + if (avroProducer != null) { |
| 104 | + avroProducer.close(); |
| 105 | + } |
| 106 | + MockSchemaRegistry.dropScope(SCHEMA_REGISTRY_SCOPE); |
| 107 | + } |
| 108 | + |
| 109 | + @Test |
| 110 | + void testSchemaEvolutionWithMultipleTopicsAndAvroSr() throws Exception { |
| 111 | + // given |
| 112 | + final Map<String, String> config = createConnectorConfig(); |
| 113 | + config.put(ConnectorConfig.VALUE_CONVERTER_CLASS_CONFIG, AvroConverter.class.getName()); |
| 114 | + config.put("value.converter.schema.registry.url", MOCK_SCHEMA_REGISTRY_URL); |
| 115 | + connectCluster.configureConnector(connectorName, config); |
| 116 | + waitForConnectorRunning(connectorName); |
| 117 | + |
| 118 | + // when |
| 119 | + sendRecordsToTopic0(); |
| 120 | + sendRecordsToTopic1(); |
| 121 | + |
| 122 | + // then |
| 123 | + final int expectedTotalRecords = TOPIC_COUNT * RECORD_COUNT; |
| 124 | + assertWithRetry(() -> snowflake.tableExist(tableName)); |
| 125 | + assertWithRetry(() -> TestUtils.getNumberOfRows(tableName) == expectedTotalRecords); |
| 126 | + assertTableColumnCount(tableName, COL_NUM); |
| 127 | + TestUtils.checkTableSchema(tableName, EXPECTED_SCHEMA); |
| 128 | + } |
| 129 | + |
| 130 | + private KafkaProducer<String, Object> createAvroProducer() { |
| 131 | + final Properties props = new Properties(); |
| 132 | + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, connectCluster.kafka().bootstrapServers()); |
| 133 | + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); |
| 134 | + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class.getName()); |
| 135 | + props.put("schema.registry.url", MOCK_SCHEMA_REGISTRY_URL); |
| 136 | + return new KafkaProducer<>(props, new StringSerializer(), createAvroSerializer()); |
| 137 | + } |
| 138 | + |
| 139 | + private KafkaAvroSerializer createAvroSerializer() { |
| 140 | + final SchemaRegistryClient schemaRegistryClient = |
| 141 | + MockSchemaRegistry.getClientForScope(SCHEMA_REGISTRY_SCOPE); |
| 142 | + final KafkaAvroSerializer serializer = new KafkaAvroSerializer(schemaRegistryClient); |
| 143 | + serializer.configure(Map.of("schema.registry.url", MOCK_SCHEMA_REGISTRY_URL), false); |
| 144 | + return serializer; |
| 145 | + } |
| 146 | + |
| 147 | + private void sendRecordsToTopic0() { |
| 148 | + final Schema schema = new Schema.Parser().parse(VALUE_SCHEMA_0); |
| 149 | + for (int i = 0; i < RECORD_COUNT; i++) { |
| 150 | + final GenericRecord record = createTopic0Record(schema); |
| 151 | + avroProducer.send(new ProducerRecord<>(topic0, "key-" + i, record)); |
| 152 | + } |
| 153 | + avroProducer.flush(); |
| 154 | + } |
| 155 | + |
| 156 | + private void sendRecordsToTopic1() { |
| 157 | + final Schema schema = new Schema.Parser().parse(VALUE_SCHEMA_1); |
| 158 | + for (int i = 0; i < RECORD_COUNT; i++) { |
| 159 | + final GenericRecord record = createTopic1Record(schema); |
| 160 | + avroProducer.send(new ProducerRecord<>(topic1, "key-" + i, record)); |
| 161 | + } |
| 162 | + avroProducer.flush(); |
| 163 | + } |
| 164 | + |
| 165 | + private GenericRecord createTopic0Record(final Schema schema) { |
| 166 | + Schema decimalSchema = schema.getField(DECIMAL).schema(); |
| 167 | + LogicalTypes.Decimal decimalType = (LogicalTypes.Decimal) decimalSchema.getLogicalType(); |
| 168 | + BigDecimal value = new BigDecimal("0.03125"); |
| 169 | + BigDecimal scaledValue = value.setScale(decimalType.getScale(), BigDecimal.ROUND_HALF_UP); |
| 170 | + ByteBuffer byteBuffer = |
| 171 | + new Conversions.DecimalConversion().toBytes(scaledValue, decimalSchema, decimalType); |
| 172 | + |
| 173 | + final GenericRecord record = new GenericData.Record(schema); |
| 174 | + record.put(PERFORMANCE_STRING, "Excellent"); |
| 175 | + record.put(PERFORMANCE_CHAR, "A"); |
| 176 | + record.put(RATING_INT, 100); |
| 177 | + record.put(TIME_MILLIS, 10); |
| 178 | + record.put(TIMESTAMP_MILLIS, 12); |
| 179 | + record.put(DECIMAL, byteBuffer); |
| 180 | + record.put(DATE, 11); |
| 181 | + return record; |
| 182 | + } |
| 183 | + |
| 184 | + private GenericRecord createTopic1Record(final Schema schema) { |
| 185 | + final GenericRecord record = new GenericData.Record(schema); |
| 186 | + record.put(PERFORMANCE_STRING, "Excellent"); |
| 187 | + record.put(RATING_DOUBLE, 0.99f); |
| 188 | + record.put(APPROVAL, true); |
| 189 | + record.put(SOME_FLOAT_NAN, Float.NaN); |
| 190 | + return record; |
| 191 | + } |
| 192 | +} |
0 commit comments