diff --git a/docs/content.zh/docs/connectors/table/formats/_index.md b/docs/content.zh/docs/connectors/table/formats/_index.md
new file mode 100644
index 000000000..531814b3e
--- /dev/null
+++ b/docs/content.zh/docs/connectors/table/formats/_index.md
@@ -0,0 +1,33 @@
+---
+title: "Formats"
+weight: 10
+type: docs
+---
+
+
+# Formats
+
+This section describes the format factories available for use with the AWS connectors (Kinesis, Firehose, etc.) in Flink SQL.
+
+## AWS Glue Schema Registry Formats
+
+- [Avro (avro-glue)]({{< ref "docs/connectors/table/formats/avro-glue" >}})
+- [JSON (json-glue)]({{< ref "docs/connectors/table/formats/json-glue" >}})
+- [Protobuf (protobuf-glue)]({{< ref "docs/connectors/table/formats/protobuf-glue" >}})
diff --git a/docs/content/docs/connectors/table/formats/_index.md b/docs/content/docs/connectors/table/formats/_index.md
new file mode 100644
index 000000000..f1f3e48bf
--- /dev/null
+++ b/docs/content/docs/connectors/table/formats/_index.md
@@ -0,0 +1,35 @@
+---
+title: "Formats"
+weight: 10
+type: docs
+---
+
+
+# Formats
+
+This section describes the format factories available for use with the AWS connectors (Kinesis, Firehose, etc.) in Flink SQL.
+
+## AWS Glue Schema Registry Formats
+
+The following formats integrate with [AWS Glue Schema Registry](https://docs.aws.amazon.com/glue/latest/dg/schema-registry.html) for schema management:
+
+- [Avro (avro-glue)]({{< ref "docs/connectors/table/formats/avro-glue" >}})
+- [JSON (json-glue)]({{< ref "docs/connectors/table/formats/json-glue" >}})
+- [Protobuf (protobuf-glue)]({{< ref "docs/connectors/table/formats/protobuf-glue" >}})
diff --git a/docs/content/docs/connectors/table/formats/avro-glue.md b/docs/content/docs/connectors/table/formats/avro-glue.md
new file mode 100644
index 000000000..630c78493
--- /dev/null
+++ b/docs/content/docs/connectors/table/formats/avro-glue.md
@@ -0,0 +1,376 @@
+---
+title: "Avro (Glue Schema Registry)"
+weight: 1
+type: docs
+---
+
+
+# Avro Format (AWS Glue Schema Registry)
+
+{{< label "Format: Serialization Schema" >}}
+{{< label "Format: Deserialization Schema" >}}
+
+The Avro Glue Schema Registry format (`avro-glue`) allows you to read and write Avro data with schemas managed by [AWS Glue Schema Registry](https://docs.aws.amazon.com/glue/latest/dg/schema-registry.html).
+
+Dependencies
+------------
+
+{{< sql_connector_download_table "avro-glue" >}}
+
+The Avro-Glue format is not part of the binary distribution.
+See how to link with it for cluster execution [here]({{< ref "docs/dev/configuration/overview" >}}).
+
+#### SQL Client JAR
+
+For SQL Client usage, download the fat JAR `flink-sql-avro-glue-schema-registry` from the table above and place it in the `lib/` directory of your Flink installation. The SQL JAR bundles all required dependencies including the AWS Glue Schema Registry serializer/deserializer libraries.
+
+#### Maven Dependency
+
+To use the format in a DataStream or Table API program, add the following dependency to your project:
+
+```xml
+
+ org.apache.flink
+ flink-avro-glue-schema-registry
+ 6.0.0
+
+```
+
+How to create a table with Avro-Glue format
+--------------------------------------------
+
+Here is an example to create a table using the Kinesis connector with the Avro-Glue format:
+
+```sql
+CREATE TABLE KinesisTable (
+ `user_id` BIGINT,
+ `item_id` BIGINT,
+ `category` STRING,
+ `behavior` STRING,
+ `ts` TIMESTAMP(3)
+) WITH (
+ 'connector' = 'kinesis',
+ 'stream.arn' = 'arn:aws:kinesis:us-east-1:012345678901:stream/my-stream',
+ 'aws.region' = 'us-east-1',
+ 'source.init.position' = 'LATEST',
+ 'format' = 'avro-glue',
+ 'avro-glue.aws.region' = 'us-east-1',
+ 'avro-glue.registry.name' = 'my-registry',
+ 'avro-glue.schema.name' = 'my-avro-schema'
+);
+```
+
+
+Schema Namespace Override
+-------------------------
+
+Flink's `AvroSchemaConverter` auto-generates an Avro schema from the SQL table definition. The generated schema uses a default namespace (`org.apache.flink.avro.generated`) and record name (`record`) that may differ from schemas already registered in Glue Schema Registry.
+
+If your GSR registry already contains a schema with a specific namespace or record name, you can override the auto-generated values using the `avro.namespace` and `avro.record-name` options:
+
+```sql
+CREATE TABLE KinesisTable (
+ `user_id` BIGINT,
+ `item_id` BIGINT,
+ `category` STRING
+) WITH (
+ 'connector' = 'kinesis',
+ 'stream.arn' = 'arn:aws:kinesis:us-east-1:012345678901:stream/my-stream',
+ 'aws.region' = 'us-east-1',
+ 'format' = 'avro-glue',
+ 'avro-glue.aws.region' = 'us-east-1',
+ 'avro-glue.registry.name' = 'my-registry',
+ 'avro-glue.schema.name' = 'my-avro-schema',
+ 'avro-glue.avro.namespace' = 'com.mycompany.events',
+ 'avro-glue.avro.record-name' = 'UserEvent'
+);
+```
+
+Alternatively, you can set `schema.fetchFromRegistry` to `true` to fetch the schema directly from GSR at runtime instead of using the auto-generated one:
+
+```sql
+CREATE TABLE KinesisTable (
+ `user_id` BIGINT,
+ `item_id` BIGINT,
+ `category` STRING
+) WITH (
+ 'connector' = 'kinesis',
+ 'stream.arn' = 'arn:aws:kinesis:us-east-1:012345678901:stream/my-stream',
+ 'aws.region' = 'us-east-1',
+ 'format' = 'avro-glue',
+ 'avro-glue.aws.region' = 'us-east-1',
+ 'avro-glue.registry.name' = 'my-registry',
+ 'avro-glue.schema.name' = 'my-avro-schema',
+ 'avro-glue.schema.fetchFromRegistry' = 'true'
+);
+```
+
+If the schema is not found in GSR, the format falls back to the auto-generated schema (optionally patched with `avro.namespace` and `avro.record-name` if provided).
+
+Format Options
+--------------
+
+
+
+
+ | Option |
+ Required |
+ Forwarded |
+ Default |
+ Type |
+ Description |
+
+
+
+
+ format |
+ required |
+ no |
+ (none) |
+ String |
+ Specify the format identifier. Use 'avro-glue'. |
+
+
+ avro-glue.aws.region |
+ required |
+ yes |
+ (none) |
+ String |
+ AWS region for the Glue Schema Registry. |
+
+
+ avro-glue.registry.name |
+ required |
+ yes |
+ (none) |
+ String |
+ Name of the Glue Schema Registry. |
+
+
+ avro-glue.schema.name |
+ required |
+ yes |
+ (none) |
+ String |
+ Schema name under which to register/look up the schema in Glue Schema Registry. |
+
+
+ avro-glue.aws.endpoint |
+ optional |
+ yes |
+ (none) |
+ String |
+ Custom AWS endpoint URL for Glue Schema Registry. |
+
+
+ avro-glue.schema.type |
+ optional |
+ yes |
+ GENERIC_RECORD |
+ String |
+ Avro record type. Supported values: GENERIC_RECORD, SPECIFIC_RECORD. |
+
+
+ avro-glue.avro.namespace |
+ optional |
+ yes |
+ (none) |
+ String |
+ Override the namespace in the auto-generated Avro schema. Use this to match schemas already registered in GSR with a different namespace. |
+
+
+ avro-glue.avro.record-name |
+ optional |
+ yes |
+ (none) |
+ String |
+ Override the record name in the auto-generated Avro schema. Use this to match schemas already registered in GSR with a different record name. |
+
+
+ avro-glue.schema.fetchFromRegistry |
+ optional |
+ yes |
+ false |
+ Boolean |
+ Whether to fetch the schema from GSR instead of using the auto-generated one. Falls back to auto-generated schema if the schema is not found. |
+
+
+ avro-glue.cache.size |
+ optional |
+ yes |
+ 200 |
+ Integer |
+ Maximum number of items in the schema cache. |
+
+
+ avro-glue.cache.ttlMs |
+ optional |
+ yes |
+ 86400000 |
+ Long |
+ Cache TTL in milliseconds. Defaults to 1 day (86400000 ms). |
+
+
+ avro-glue.schema.autoRegistration |
+ optional |
+ yes |
+ false |
+ Boolean |
+ Whether to auto-register schemas with Glue Schema Registry when writing data. |
+
+
+ avro-glue.schema.compatibility |
+ optional |
+ yes |
+ NONE |
+ String |
+ Schema compatibility mode. Supported values: NONE, DISABLED, BACKWARD, BACKWARD_ALL, FORWARD, FORWARD_ALL, FULL, FULL_ALL. |
+
+
+ avro-glue.schema.compression |
+ optional |
+ yes |
+ NONE |
+ String |
+ Compression type for schema data. Supported values: NONE, ZLIB. |
+
+
+
+
+Data Type Mapping
+-----------------
+
+The Avro-Glue format uses Flink's built-in `AvroSchemaConverter` to map between Flink SQL types and Avro types. The mapping follows the same rules as the standard Flink Avro format:
+
+
+
+
+ | Flink SQL Type |
+ Avro Type |
+
+
+
+
+ BOOLEAN |
+ boolean |
+
+
+ TINYINT / SMALLINT / INT |
+ int |
+
+
+ BIGINT |
+ long |
+
+
+ FLOAT |
+ float |
+
+
+ DOUBLE |
+ double |
+
+
+ STRING |
+ string |
+
+
+ BYTES |
+ bytes |
+
+
+ DECIMAL |
+ bytes (logical type: decimal) |
+
+
+ DATE |
+ int (logical type: date) |
+
+
+ TIME |
+ int (logical type: time-millis) |
+
+
+ TIMESTAMP |
+ long (logical type: timestamp-millis) |
+
+
+ ARRAY |
+ array |
+
+
+ MAP (key must be STRING) |
+ map |
+
+
+ ROW |
+ record |
+
+
+
+
+{{< hint info >}}
+Nullable Flink SQL types are mapped to Avro union types `["null", "type"]`. Flink SQL types are nullable by default, so the auto-generated Avro schema will use union types for all fields unless `NOT NULL` constraints are specified.
+{{< /hint >}}
+
+Usage with Kinesis and Firehose Connectors
+------------------------------------------
+
+The Avro-Glue format can be used with any Flink SQL connector that supports custom formats. Here are examples with the Kinesis and Firehose connectors:
+
+### Kinesis Source
+
+```sql
+CREATE TABLE KinesisSource (
+ `user_id` BIGINT,
+ `event_type` STRING,
+ `payload` STRING,
+ `event_time` TIMESTAMP(3)
+) WITH (
+ 'connector' = 'kinesis',
+ 'stream.arn' = 'arn:aws:kinesis:us-east-1:012345678901:stream/events',
+ 'aws.region' = 'us-east-1',
+ 'source.init.position' = 'LATEST',
+ 'format' = 'avro-glue',
+ 'avro-glue.aws.region' = 'us-east-1',
+ 'avro-glue.registry.name' = 'my-registry',
+ 'avro-glue.schema.name' = 'events-avro'
+);
+```
+
+### Firehose Sink
+
+```sql
+CREATE TABLE FirehoseSink (
+ `user_id` BIGINT,
+ `event_type` STRING,
+ `payload` STRING,
+ `event_time` TIMESTAMP(3)
+) WITH (
+ 'connector' = 'firehose',
+ 'delivery-stream' = 'my-delivery-stream',
+ 'aws.region' = 'us-east-1',
+ 'format' = 'avro-glue',
+ 'avro-glue.aws.region' = 'us-east-1',
+ 'avro-glue.registry.name' = 'my-registry',
+ 'avro-glue.schema.name' = 'events-avro',
+ 'avro-glue.schema.autoRegistration' = 'true'
+);
+```
diff --git a/docs/content/docs/connectors/table/formats/protobuf-glue.md b/docs/content/docs/connectors/table/formats/protobuf-glue.md
new file mode 100644
index 000000000..ac6147e5a
--- /dev/null
+++ b/docs/content/docs/connectors/table/formats/protobuf-glue.md
@@ -0,0 +1,283 @@
+---
+title: "Protobuf (Glue Schema Registry)"
+weight: 3
+type: docs
+---
+
+
+# Protobuf Format (AWS Glue Schema Registry)
+
+{{< label "Format: Serialization Schema" >}}
+{{< label "Format: Deserialization Schema" >}}
+
+The Protobuf Glue Schema Registry format (`protobuf-glue`) allows you to read and write Protocol Buffers data with schemas managed by [AWS Glue Schema Registry](https://docs.aws.amazon.com/glue/latest/dg/schema-registry.html).
+
+Dependencies
+------------
+
+{{< sql_connector_download_table "protobuf-glue" >}}
+
+The Protobuf-Glue format is not part of the binary distribution.
+See how to link with it for cluster execution [here]({{< ref "docs/dev/configuration/overview" >}}).
+
+#### SQL Client JAR
+
+For SQL Client usage, download the fat JAR `flink-sql-protobuf-glue-schema-registry` from the table above and place it in the `lib/` directory of your Flink installation. The SQL JAR bundles all required dependencies including the AWS Glue Schema Registry serializer/deserializer libraries.
+
+#### Maven Dependency
+
+To use the format in a DataStream or Table API program, add the following dependency to your project:
+
+```xml
+
+ org.apache.flink
+ flink-protobuf-glue-schema-registry
+ 6.0.0
+
+```
+
+How to create a table with Protobuf-Glue format
+-------------------------------------------------
+
+Here is an example to create a table using the Kinesis connector with the Protobuf-Glue format:
+
+```sql
+CREATE TABLE KinesisTable (
+ `user_id` BIGINT,
+ `item_id` BIGINT,
+ `category` STRING,
+ `behavior` STRING,
+ `ts` TIMESTAMP(3)
+) WITH (
+ 'connector' = 'kinesis',
+ 'stream.arn' = 'arn:aws:kinesis:us-east-1:012345678901:stream/my-stream',
+ 'aws.region' = 'us-east-1',
+ 'source.init.position' = 'LATEST',
+ 'format' = 'protobuf-glue',
+ 'protobuf-glue.aws.region' = 'us-east-1',
+ 'protobuf-glue.registry.name' = 'my-registry',
+ 'protobuf-glue.schema.name' = 'my-protobuf-schema'
+);
+```
+
+
+Protobuf Descriptor Auto-Generation
+-------------------------------------
+
+When writing data (sink), the Protobuf-Glue format automatically generates a Protobuf descriptor (`.proto` schema definition) from the Flink table schema and registers it with Glue Schema Registry using `DataFormat.PROTOBUF`.
+
+When reading data (source), the format strips the GSR header bytes (18 bytes: 1 header version byte + 1 compression byte + 16 UUID bytes) from incoming records and deserializes the Protobuf payload into Flink `RowData`.
+
+Format Options
+--------------
+
+
+
+
+ | Option |
+ Required |
+ Forwarded |
+ Default |
+ Type |
+ Description |
+
+
+
+
+ format |
+ required |
+ no |
+ (none) |
+ String |
+ Specify the format identifier. Use 'protobuf-glue'. |
+
+
+ protobuf-glue.aws.region |
+ required |
+ yes |
+ (none) |
+ String |
+ AWS region for the Glue Schema Registry. |
+
+
+ protobuf-glue.registry.name |
+ required |
+ yes |
+ (none) |
+ String |
+ Name of the Glue Schema Registry. |
+
+
+ protobuf-glue.schema.name |
+ required |
+ yes |
+ (none) |
+ String |
+ Schema name under which to register/look up the schema in Glue Schema Registry. |
+
+
+ protobuf-glue.aws.endpoint |
+ optional |
+ yes |
+ (none) |
+ String |
+ Custom AWS endpoint URL for Glue Schema Registry. |
+
+
+ protobuf-glue.cache.size |
+ optional |
+ yes |
+ 200 |
+ Integer |
+ Maximum number of items in the schema cache. |
+
+
+ protobuf-glue.cache.ttlMs |
+ optional |
+ yes |
+ 86400000 |
+ Long |
+ Cache TTL in milliseconds. Defaults to 1 day (86400000 ms). |
+
+
+ protobuf-glue.schema.autoRegistration |
+ optional |
+ yes |
+ false |
+ Boolean |
+ Whether to auto-register schemas with Glue Schema Registry when writing data. |
+
+
+ protobuf-glue.schema.compatibility |
+ optional |
+ yes |
+ NONE |
+ String |
+ Schema compatibility mode. Supported values: NONE, DISABLED, BACKWARD, BACKWARD_ALL, FORWARD, FORWARD_ALL, FULL, FULL_ALL. |
+
+
+ protobuf-glue.schema.compression |
+ optional |
+ yes |
+ NONE |
+ String |
+ Compression type for schema data. Supported values: NONE, ZLIB. |
+
+
+
+
+Data Type Mapping
+-----------------
+
+The Protobuf-Glue format maps between Flink SQL types and Protobuf types as follows:
+
+
+
+
+ | Flink SQL Type |
+ Protobuf Type |
+
+
+
+
+ BOOLEAN |
+ bool |
+
+
+ INT |
+ int32 |
+
+
+ BIGINT |
+ int64 |
+
+
+ FLOAT |
+ float |
+
+
+ DOUBLE |
+ double |
+
+
+ STRING |
+ string |
+
+
+ BYTES |
+ bytes |
+
+
+ ARRAY |
+ repeated |
+
+
+ MAP |
+ map |
+
+
+ ROW |
+ message (nested) |
+
+
+
+
+Usage with Kinesis and Firehose Connectors
+------------------------------------------
+
+### Kinesis Source
+
+```sql
+CREATE TABLE KinesisSource (
+ `user_id` BIGINT,
+ `event_type` STRING,
+ `payload` STRING,
+ `event_time` TIMESTAMP(3)
+) WITH (
+ 'connector' = 'kinesis',
+ 'stream.arn' = 'arn:aws:kinesis:us-east-1:012345678901:stream/events',
+ 'aws.region' = 'us-east-1',
+ 'source.init.position' = 'LATEST',
+ 'format' = 'protobuf-glue',
+ 'protobuf-glue.aws.region' = 'us-east-1',
+ 'protobuf-glue.registry.name' = 'my-registry',
+ 'protobuf-glue.schema.name' = 'events-protobuf'
+);
+```
+
+### Firehose Sink
+
+```sql
+CREATE TABLE FirehoseSink (
+ `user_id` BIGINT,
+ `event_type` STRING,
+ `payload` STRING,
+ `event_time` TIMESTAMP(3)
+) WITH (
+ 'connector' = 'firehose',
+ 'delivery-stream' = 'my-delivery-stream',
+ 'aws.region' = 'us-east-1',
+ 'format' = 'protobuf-glue',
+ 'protobuf-glue.aws.region' = 'us-east-1',
+ 'protobuf-glue.registry.name' = 'my-registry',
+ 'protobuf-glue.schema.name' = 'events-protobuf',
+ 'protobuf-glue.schema.autoRegistration' = 'true'
+);
+```
diff --git a/docs/data/avro-glue.yml b/docs/data/avro-glue.yml
new file mode 100644
index 000000000..a84bfcc2a
--- /dev/null
+++ b/docs/data/avro-glue.yml
@@ -0,0 +1,23 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+version: 6.0.0
+flink_compatibility: [ "2.0" ]
+variants:
+ - maven: flink-avro-glue-schema-registry
+ sql_url: https://repo.maven.apache.org/maven2/org/apache/flink/flink-sql-avro-glue-schema-registry/$full_version/flink-sql-avro-glue-schema-registry-$full_version.jar
diff --git a/docs/data/protobuf-glue.yml b/docs/data/protobuf-glue.yml
new file mode 100644
index 000000000..a759a0245
--- /dev/null
+++ b/docs/data/protobuf-glue.yml
@@ -0,0 +1,23 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+version: 6.0.0
+flink_compatibility: [ "2.0" ]
+variants:
+ - maven: flink-protobuf-glue-schema-registry
+ sql_url: https://repo.maven.apache.org/maven2/org/apache/flink/flink-sql-protobuf-glue-schema-registry/$full_version/flink-sql-protobuf-glue-schema-registry-$full_version.jar
diff --git a/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/pom.xml b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/pom.xml
index 3fedf34b9..6a1963e73 100644
--- a/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/pom.xml
+++ b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/pom.xml
@@ -33,22 +33,92 @@ under the License.
jar
+
org.apache.flink
flink-avro-glue-schema-registry
${project.version}
+
+
+
+
+ org.apache.flink
+ flink-table-api-java-bridge
+ ${flink.version}
+
+
+ org.apache.flink
+ flink-table-planner-loader
+ ${flink.version}
+
+
+ org.apache.flink
+ flink-table-runtime
+ ${flink.version}
+
+
+
+
+ org.apache.flink
+ flink-connector-aws-kinesis-streams
+ ${project.version}
+
+
+
+
+ software.amazon.awssdk
+ kinesis
+
+
+
+
+ software.amazon.awssdk
+ sts
+
+
+
+
+ software.amazon.awssdk
+ netty-nio-client
+
+
+
+
+ software.amazon.awssdk
+ s3
test
+
+
org.apache.flink
flink-connector-aws-base
${project.version}
- test-jar
- test
+
+
org.apache.flink
- flink-connector-aws-kinesis-streams
+ flink-avro
+ ${flink.version}
+
+
+
+
+ org.apache.flink
+ flink-streaming-java
+ ${flink.version}
+
+
+ org.apache.flink
+ flink-clients
+ ${flink.version}
+
+
+
+
+ org.apache.flink
+ flink-connector-aws-base
${project.version}
test-jar
test
@@ -57,12 +127,48 @@ under the License.
org.apache.flink
flink-connector-aws-kinesis-streams
${project.version}
+ test-jar
test
+
+
- software.amazon.awssdk
- s3
+ org.testcontainers
+ localstack
test
+
+
+
+ org.slf4j
+ slf4j-api
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ 2.24.1
+
+
+ org.apache.logging.log4j
+ log4j-core
+ 2.24.1
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ **/GlueSchemaRegistryAvroKinesisITCase.java
+ **/GSRKinesisPubsubClient.java
+
+
+
+
+
diff --git a/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/main/resources/log4j2.properties b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/main/resources/log4j2.properties
new file mode 100644
index 000000000..cac90c928
--- /dev/null
+++ b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/main/resources/log4j2.properties
@@ -0,0 +1,41 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+rootLogger.level = INFO
+rootLogger.appenderRef.console.ref = ConsoleAppender
+
+appender.console.name = ConsoleAppender
+appender.console.type = Console
+appender.console.layout.type = PatternLayout
+appender.console.layout.pattern = %d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n
+
+# Reduce noise from Flink internals
+logger.flink.name = org.apache.flink
+logger.flink.level = WARN
+
+# Our test class at INFO
+logger.e2e.name = org.apache.flink.glue.schema.registry.test
+logger.e2e.level = INFO
+
+# GSR format classes at INFO (for debugging)
+logger.gsrformat.name = org.apache.flink.formats.avro.glue.schema.registry
+logger.gsrformat.level = INFO
+
+# GSR SDK
+logger.gsr.name = software.amazon.awssdk
+logger.gsr.level = WARN
diff --git a/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/java/org/apache/flink/glue/schema/registry/test/GlueSchemaRegistryAvroSqlKinesisITCase.java b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/java/org/apache/flink/glue/schema/registry/test/GlueSchemaRegistryAvroSqlKinesisITCase.java
new file mode 100644
index 000000000..15376f6d7
--- /dev/null
+++ b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/java/org/apache/flink/glue/schema/registry/test/GlueSchemaRegistryAvroSqlKinesisITCase.java
@@ -0,0 +1,679 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.glue.schema.registry.test;
+
+import org.apache.flink.api.common.time.Deadline;
+import org.apache.flink.connector.aws.testutils.AWSServicesTestUtils;
+import org.apache.flink.connector.aws.testutils.LocalstackContainer;
+import org.apache.flink.connector.aws.util.AWSGeneralUtil;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
+import org.apache.flink.test.junit5.MiniClusterExtension;
+import org.apache.flink.types.Row;
+import org.apache.flink.util.CloseableIterator;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.utility.DockerImageName;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.glue.GlueClient;
+import software.amazon.awssdk.services.glue.model.DeleteSchemaRequest;
+import software.amazon.awssdk.services.glue.model.SchemaId;
+import software.amazon.awssdk.services.kinesis.KinesisClient;
+import software.amazon.awssdk.services.kinesis.model.CreateStreamRequest;
+import software.amazon.awssdk.services.kinesis.model.DescribeStreamRequest;
+import software.amazon.awssdk.services.kinesis.model.StreamStatus;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assumptions.assumeThat;
+
+/**
+ * End-to-end test for the {@code avro-glue} Flink SQL format factory.
+ *
+ * This is the SQL-path counterpart of {@link GlueSchemaRegistryAvroKinesisITCase} (which
+ * exercises the DataStream API). Kinesis I/O runs against a Localstack container; the Glue Schema
+ * Registry calls go to real AWS. The class is gated on the {@code
+ * IT_CASE_GLUE_SCHEMA_ACCESS_KEY} / {@code IT_CASE_GLUE_SCHEMA_SECRET_KEY} environment variables
+ * and tagged {@code requires-aws-credentials}, so it is excluded from the credential-free {@code
+ * run-end-to-end-tests} profile and only runs under {@code run-aws-end-to-end-tests}. Without
+ * credentials it skips cleanly (the container is never started).
+ *
+ *
Each test uses its own Localstack Kinesis stream and a run-unique GSR schema name (timestamp
+ * suffix) so re-runs do not collide. Created GSR schemas are best-effort deleted in {@link
+ * #afterAll()}.
+ */
+@ExtendWith(MiniClusterExtension.class)
+@Tag("requires-aws-credentials")
+class GlueSchemaRegistryAvroSqlKinesisITCase {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(GlueSchemaRegistryAvroSqlKinesisITCase.class);
+
+ private static final String ACCESS_KEY = System.getenv("IT_CASE_GLUE_SCHEMA_ACCESS_KEY");
+ private static final String SECRET_KEY = System.getenv("IT_CASE_GLUE_SCHEMA_SECRET_KEY");
+
+ /** Region for the real Glue Schema Registry calls. Overridable for the test account. */
+ private static final String GSR_REGION =
+ envOrDefault("IT_CASE_GLUE_SCHEMA_REGION", "ca-central-1");
+
+ /** Registry that backs the schemas; GSR's implicit default registry when unset. */
+ private static final String REGISTRY_NAME =
+ envOrDefault("IT_CASE_GLUE_SCHEMA_REGISTRY", "default-registry");
+
+ /** Region used for the Localstack Kinesis endpoint (mirrors the DataStream ITCase). */
+ private static final String KINESIS_REGION = "ap-southeast-1";
+
+ private static final String LOCALSTACK_DOCKER_IMAGE_VERSION = "localstack/localstack:3.7.2";
+
+ /** Unique per JVM run so parallel/repeat runs never reuse a GSR schema name. */
+ private static final String RUN_ID = String.valueOf(System.currentTimeMillis());
+
+ /** GSR schema names created during the run, for best-effort teardown. */
+ private static final Set CREATED_SCHEMAS = new LinkedHashSet<>();
+
+ private static final LocalstackContainer LOCALSTACK =
+ new LocalstackContainer(DockerImageName.parse(LOCALSTACK_DOCKER_IMAGE_VERSION))
+ .withNetworkAliases("localstack");
+
+ private static SdkHttpClient httpClient;
+ private static KinesisClient kinesisClient;
+
+ private StreamTableEnvironment tEnv;
+
+ @BeforeAll
+ static void beforeAll() {
+ assumeThat(ACCESS_KEY)
+ .as("IT_CASE_GLUE_SCHEMA_ACCESS_KEY not configured, skipping test")
+ .isNotBlank();
+ assumeThat(SECRET_KEY)
+ .as("IT_CASE_GLUE_SCHEMA_SECRET_KEY not configured, skipping test")
+ .isNotBlank();
+
+ System.setProperty(SdkSystemSetting.CBOR_ENABLED.property(), "false");
+
+ LOCALSTACK.start();
+ httpClient = AWSServicesTestUtils.createHttpClient();
+ kinesisClient =
+ AWSServicesTestUtils.createAwsSyncClient(
+ LOCALSTACK.getEndpoint(), httpClient, KinesisClient.builder());
+ LOG.info("Localstack Kinesis endpoint ready at {}", LOCALSTACK.getEndpoint());
+ }
+
+ @AfterAll
+ static void afterAll() {
+ deleteCreatedSchemas();
+ AWSGeneralUtil.closeResources(httpClient, kinesisClient);
+ if (LOCALSTACK.isRunning()) {
+ LOCALSTACK.stop();
+ }
+ System.clearProperty(SdkSystemSetting.CBOR_ENABLED.property());
+ }
+
+ @BeforeEach
+ void setUp() {
+ // The SQL format's GSR client resolves credentials from the default chain inside the
+ // MiniCluster JVM; expose the real IT credentials via system properties.
+ System.setProperty(SdkSystemSetting.AWS_ACCESS_KEY_ID.property(), ACCESS_KEY);
+ System.setProperty(SdkSystemSetting.AWS_SECRET_ACCESS_KEY.property(), SECRET_KEY);
+ System.setProperty(SdkSystemSetting.AWS_REGION.property(), GSR_REGION);
+
+ StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
+ env.setParallelism(1);
+ tEnv = StreamTableEnvironment.create(env);
+ }
+
+ @AfterEach
+ void tearDown() {
+ System.clearProperty(SdkSystemSetting.AWS_ACCESS_KEY_ID.property());
+ System.clearProperty(SdkSystemSetting.AWS_SECRET_ACCESS_KEY.property());
+ System.clearProperty(SdkSystemSetting.AWS_REGION.property());
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Scenarios (ported one-to-one from the AvroGlueSqlE2E manual driver)
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ void basicRoundTrip() throws Exception {
+ String streamArn = createStream("gsr_avro_sql_basic");
+ String schemaName = schemaName("basic");
+ String columns = "user_name STRING, favorite_number INT, favorite_color STRING";
+ List formatOpts =
+ autoRegOpts(schemaName, "avro-glue.schema.autoRegistration", "true");
+
+ createKinesisTable("basic_sink", columns, streamArn, false, formatOpts);
+ tEnv.executeSql(
+ "INSERT INTO basic_sink VALUES "
+ + "('Alice', 42, 'blue'),"
+ + "('Bob', 7, 'green'),"
+ + "('Charlie', 99, 'red')")
+ .await(120, TimeUnit.SECONDS);
+
+ createKinesisTable("basic_source", columns, streamArn, true, sourceOpts(schemaName));
+ List rows = collect("SELECT * FROM basic_source", 3, Duration.ofSeconds(90));
+
+ List names = firstFieldStrings(rows);
+ assertThat(names).contains("Alice", "Bob", "Charlie");
+ }
+
+ @Test
+ void customNamespaceAndRecordName() throws Exception {
+ String streamArn = createStream("gsr_avro_sql_custom_ns");
+ String schemaName = schemaName("custom-ns");
+ String columns = "user_name STRING, favorite_number INT, favorite_color STRING";
+ List formatOpts =
+ autoRegOpts(
+ schemaName,
+ "avro-glue.schema.autoRegistration",
+ "true",
+ "avro-glue.avro.namespace",
+ "com.example.events",
+ "avro-glue.avro.record-name",
+ "UserEvent");
+
+ createKinesisTable("custom_ns_sink", columns, streamArn, false, formatOpts);
+ tEnv.executeSql(
+ "INSERT INTO custom_ns_sink VALUES "
+ + "('Dave', 13, 'yellow'),"
+ + "('Eve', 55, 'purple')")
+ .await(120, TimeUnit.SECONDS);
+
+ createKinesisTable("custom_ns_source", columns, streamArn, true, sourceOpts(schemaName));
+ List rows = collect("SELECT * FROM custom_ns_source", 2, Duration.ofSeconds(90));
+
+ assertThat(firstFieldStrings(rows)).contains("Dave", "Eve");
+ }
+
+ @Test
+ void fetchFromRegistry() throws Exception {
+ String streamArn = createStream("gsr_avro_sql_fetch");
+ String schemaName = schemaName("fetch");
+ String columns = "user_name STRING, favorite_number INT, favorite_color STRING";
+
+ // Seed the schema in GSR (explicit namespace/record-name) via auto-registration.
+ createKinesisTable(
+ "fetch_seed_sink",
+ columns,
+ streamArn,
+ false,
+ autoRegOpts(
+ schemaName,
+ "avro-glue.schema.autoRegistration",
+ "true",
+ "avro-glue.avro.namespace",
+ "com.example.events",
+ "avro-glue.avro.record-name",
+ "UserEvent"));
+ tEnv.executeSql("INSERT INTO fetch_seed_sink VALUES ('Seed', 0, 'none')")
+ .await(120, TimeUnit.SECONDS);
+
+ // Write with fetchFromRegistry=true (no explicit namespace) — schema fetched from GSR.
+ createKinesisTable(
+ "fetch_sink",
+ columns,
+ streamArn,
+ false,
+ autoRegOpts(schemaName, "avro-glue.schema.fetchFromRegistry", "true"));
+ tEnv.executeSql(
+ "INSERT INTO fetch_sink VALUES "
+ + "('Frank', 21, 'orange'),"
+ + "('Grace', 33, 'pink')")
+ .await(120, TimeUnit.SECONDS);
+
+ createKinesisTable("fetch_source", columns, streamArn, true, sourceOpts(schemaName));
+ List rows = collect("SELECT * FROM fetch_source", 3, Duration.ofSeconds(90));
+
+ assertThat(firstFieldStrings(rows)).contains("Frank", "Grace");
+ }
+
+ @Test
+ void complexTypes() throws Exception {
+ String streamArn = createStream("gsr_avro_sql_complex");
+ String schemaName = schemaName("complex");
+ String columns =
+ "order_id STRING,"
+ + "order_time TIMESTAMP(3),"
+ + "total_amount DECIMAL(10, 2),"
+ + "customer ROW,"
+ + "items ARRAY>,"
+ + "tags ARRAY,"
+ + "metadata MAP,"
+ + "notes STRING";
+
+ createKinesisTable(
+ "complex_sink",
+ columns,
+ streamArn,
+ false,
+ autoRegOpts(
+ schemaName,
+ "avro-glue.schema.autoRegistration",
+ "true",
+ "avro-glue.avro.namespace",
+ "com.example.orders",
+ "avro-glue.avro.record-name",
+ "OrderEvent"));
+
+ tEnv.executeSql(
+ "INSERT INTO complex_sink VALUES ("
+ + " 'ORD-001',"
+ + " TIMESTAMP '2024-01-15 10:30:00',"
+ + " CAST(199.99 AS DECIMAL(10, 2)),"
+ + " ROW('John Doe', 'john@example.com', 35),"
+ + " ARRAY[ROW('Widget', 2, CAST(49.99 AS DECIMAL(8, 2))), "
+ + " ROW('Gadget', 1, CAST(99.99 AS DECIMAL(8, 2)))],"
+ + " ARRAY['priority', 'express'],"
+ + " MAP['source', 'web', 'campaign', 'summer-sale'],"
+ + " 'Handle with care'"
+ + ")")
+ .await(120, TimeUnit.SECONDS);
+ tEnv.executeSql(
+ "INSERT INTO complex_sink VALUES ("
+ + " 'ORD-002',"
+ + " TIMESTAMP '2024-01-15 11:45:00',"
+ + " CAST(75.50 AS DECIMAL(10, 2)),"
+ + " ROW('Jane Smith', 'jane@example.com', 28),"
+ + " ARRAY[ROW('Gizmo', 3, CAST(25.00 AS DECIMAL(8, 2)))],"
+ + " ARRAY['standard'],"
+ + " MAP['source', 'mobile'],"
+ + " CAST(NULL AS STRING)"
+ + ")")
+ .await(120, TimeUnit.SECONDS);
+
+ createKinesisTable("complex_source", columns, streamArn, true, sourceOpts(schemaName));
+ List rows = collect("SELECT * FROM complex_source", 2, Duration.ofSeconds(90));
+
+ Row ord001 = findByOrderId(rows, "ORD-001");
+ Row ord002 = findByOrderId(rows, "ORD-002");
+ assertThat(ord001).as("ORD-001 present").isNotNull();
+ assertThat(ord002).as("ORD-002 present").isNotNull();
+
+ Row customer = (Row) ord001.getField(3);
+ assertThat(customer).isNotNull();
+ assertThat(customer.getField(0)).isEqualTo("John Doe");
+ assertThat(ord001.getField(7)).as("ORD-001 notes not null").isNotNull();
+ assertThat(ord002.getField(7)).as("ORD-002 notes null (nullable)").isNull();
+ }
+
+ @Test
+ void compatibilityBackwardRejectsNewRequiredField() throws Exception {
+ String streamArn = createStream("gsr_avro_sql_compat_bw");
+ String schemaName = schemaName("compat-backward");
+
+ // v1: all fields NOT NULL -> required Avro fields.
+ createKinesisTable(
+ "compat_bw_v1",
+ "user_name STRING NOT NULL, age INT NOT NULL, city STRING NOT NULL",
+ streamArn,
+ false,
+ compatOpts(schemaName, "BACKWARD"));
+ tEnv.executeSql("INSERT INTO compat_bw_v1 VALUES ('Alice', 30, 'Seattle')")
+ .await(120, TimeUnit.SECONDS);
+
+ // v2: add a nullable field -> backward compatible.
+ createKinesisTable(
+ "compat_bw_v2",
+ "user_name STRING NOT NULL, age INT NOT NULL, city STRING NOT NULL, email STRING",
+ streamArn,
+ false,
+ compatOpts(schemaName, "BACKWARD"));
+ tEnv.executeSql(
+ "INSERT INTO compat_bw_v2 VALUES ('Bob', 25, 'Portland', 'bob@example.com')")
+ .await(120, TimeUnit.SECONDS);
+
+ // v3: ADD a required (NOT NULL, no default) field -> BACKWARD violation,
+ // must be rejected. (Removing a field is backward-compatible in Avro:
+ // a new reader simply ignores the extra field in old data. The true
+ // violation is a new required reader field that old data cannot supply.)
+ createKinesisTable(
+ "compat_bw_v3",
+ "user_name STRING NOT NULL, age INT NOT NULL, city STRING NOT NULL,"
+ + " country STRING NOT NULL",
+ streamArn,
+ false,
+ compatOpts(schemaName, "BACKWARD"));
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ "INSERT INTO compat_bw_v3 VALUES"
+ + " ('Charlie', 35, 'Boston', 'USA')")
+ .await(120, TimeUnit.SECONDS))
+ .as("adding a required field without default must violate BACKWARD compatibility")
+ .isInstanceOf(Exception.class);
+ }
+
+ @Test
+ void compatibilityNoneAllowsAnyEvolution() throws Exception {
+ String streamArn = createStream("gsr_avro_sql_compat_none");
+ String schemaName = schemaName("compat-none");
+
+ createKinesisTable(
+ "compat_none_v1",
+ "user_name STRING, age INT, city STRING",
+ streamArn,
+ false,
+ compatOpts(schemaName, "NONE"));
+ tEnv.executeSql("INSERT INTO compat_none_v1 VALUES ('Dave', 40, 'Denver')")
+ .await(120, TimeUnit.SECONDS);
+
+ createKinesisTable(
+ "compat_none_v2",
+ "user_name STRING, age INT",
+ streamArn,
+ false,
+ compatOpts(schemaName, "NONE"));
+ // Removing a field is normally incompatible; NONE must allow it without throwing.
+ tEnv.executeSql("INSERT INTO compat_none_v2 VALUES ('Eve', 28)")
+ .await(120, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void compatibilityFullAllowsAddingOptional() throws Exception {
+ String streamArn = createStream("gsr_avro_sql_compat_full");
+ String schemaName = schemaName("compat-full");
+
+ createKinesisTable(
+ "compat_full_v1",
+ "user_name STRING, age INT, city STRING",
+ streamArn,
+ false,
+ compatOpts(schemaName, "FULL"));
+ tEnv.executeSql("INSERT INTO compat_full_v1 VALUES ('Frank', 45, 'Chicago')")
+ .await(120, TimeUnit.SECONDS);
+
+ createKinesisTable(
+ "compat_full_v2",
+ "user_name STRING, age INT, city STRING, email STRING",
+ streamArn,
+ false,
+ compatOpts(schemaName, "FULL"));
+ // Adding an optional field is safe in both directions -> allowed under FULL.
+ tEnv.executeSql(
+ "INSERT INTO compat_full_v2 VALUES ('Grace', 32, 'Boston', 'grace@example.com')")
+ .await(120, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void existingSchemaWithoutAutoRegistration() throws Exception {
+ String schemaName = schemaName("existing-no-autoreg");
+ String columns = "user_name STRING, favorite_number INT, favorite_color STRING";
+
+ // Phase A: register the schema (and a first version) by writing through a table that
+ // auto-registers it. This mirrors a one-time governed schema-onboarding step.
+ String seedStreamArn = createStream("gsr_avro_sql_existing_seed");
+ createKinesisTable(
+ "existing_seed_sink",
+ columns,
+ seedStreamArn,
+ false,
+ autoRegOpts(schemaName, "avro-glue.schema.autoRegistration", "true"));
+ tEnv.executeSql(
+ "INSERT INTO existing_seed_sink VALUES "
+ + "('Heidi', 11, 'teal'),"
+ + "('Ivan', 22, 'olive')")
+ .await(120, TimeUnit.SECONDS);
+
+ // Phase B: a NEW stream for isolation, SAME schema name, but autoRegistration=false. The
+ // write must succeed because the schema and a compatible version already exist in GSR —
+ // this is the production governance pattern where apps are forbidden from registering.
+ String prodStreamArn = createStream("gsr_avro_sql_existing_prod");
+ createKinesisTable(
+ "existing_prod_sink",
+ columns,
+ prodStreamArn,
+ false,
+ autoRegOpts(schemaName, "avro-glue.schema.autoRegistration", "false"));
+ tEnv.executeSql(
+ "INSERT INTO existing_prod_sink VALUES "
+ + "('Judy', 33, 'maroon'),"
+ + "('Mallory', 44, 'navy')")
+ .await(120, TimeUnit.SECONDS);
+
+ createKinesisTable(
+ "existing_prod_source", columns, prodStreamArn, true, sourceOpts(schemaName));
+ List rows = collect("SELECT * FROM existing_prod_source", 2, Duration.ofSeconds(90));
+
+ assertThat(firstFieldStrings(rows))
+ .as(
+ "rows written against a pre-existing schema without auto-registration must"
+ + " round-trip")
+ .contains("Judy", "Mallory");
+ }
+
+ @Test
+ void missingSchemaWithoutAutoRegistrationFails() throws Exception {
+ String streamArn = createStream("gsr_avro_sql_missing");
+ String schemaName = schemaName("missing-no-autoreg");
+ String columns = "user_name STRING, favorite_number INT, favorite_color STRING";
+
+ // The schema name has never been registered and autoRegistration is off, so the write must
+ // fail with a clear error rather than silently creating the schema.
+ createKinesisTable(
+ "missing_sink",
+ columns,
+ streamArn,
+ false,
+ autoRegOpts(schemaName, "avro-glue.schema.autoRegistration", "false"));
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ "INSERT INTO missing_sink VALUES ('Oscar', 1,"
+ + " 'gray')")
+ .await(120, TimeUnit.SECONDS))
+ .as(
+ "writing against a missing schema without auto-registration must fail with"
+ + " a clear error rather than silently creating the schema")
+ .isInstanceOf(Exception.class);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------------------------
+
+ private void createKinesisTable(
+ String tableName,
+ String columns,
+ String streamArn,
+ boolean source,
+ List formatOptions) {
+ List with = new ArrayList<>();
+ with.add("'connector' = 'kinesis'");
+ with.add("'stream.arn' = '" + streamArn + "'");
+ with.add("'aws.region' = '" + KINESIS_REGION + "'");
+ with.add("'aws.endpoint' = '" + LOCALSTACK.getEndpoint() + "'");
+ with.add("'aws.credentials.provider' = 'BASIC'");
+ with.add("'aws.credentials.basic.accesskeyid' = 'accessKeyId'");
+ with.add("'aws.credentials.basic.secretkey' = 'secretAccessKey'");
+ with.add("'aws.trust.all.certificates' = 'true'");
+ with.add("'aws.http.protocol.version' = 'HTTP1_1'");
+ if (source) {
+ with.add("'source.init.position' = 'TRIM_HORIZON'");
+ }
+ with.add("'format' = 'avro-glue'");
+ with.add("'avro-glue.aws.region' = '" + GSR_REGION + "'");
+ with.add("'avro-glue.registry.name' = '" + REGISTRY_NAME + "'");
+ with.addAll(formatOptions);
+
+ String ddl =
+ "CREATE TABLE "
+ + tableName
+ + " ("
+ + columns
+ + ") WITH ("
+ + String.join(", ", with)
+ + ")";
+ tEnv.executeSql(ddl);
+ }
+
+ /** Builds format options starting with the (required) schema name, then key/value pairs. */
+ private List autoRegOpts(String schemaName, String... kvPairs) {
+ List opts = new ArrayList<>();
+ opts.add("'avro-glue.schema.name' = '" + schemaName + "'");
+ for (int i = 0; i + 1 < kvPairs.length; i += 2) {
+ opts.add("'" + kvPairs[i] + "' = '" + kvPairs[i + 1] + "'");
+ }
+ return opts;
+ }
+
+ private List sourceOpts(String schemaName) {
+ return autoRegOpts(schemaName);
+ }
+
+ private List compatOpts(String schemaName, String compatibility) {
+ return autoRegOpts(
+ schemaName,
+ "avro-glue.schema.autoRegistration",
+ "true",
+ "avro-glue.schema.compatibility",
+ compatibility);
+ }
+
+ private List collect(String selectSql, int expected, Duration timeout) throws Exception {
+ List rows = new ArrayList<>();
+ try (CloseableIterator iterator = tEnv.executeSql(selectSql).collect()) {
+ Deadline deadline = Deadline.fromNow(timeout);
+ while (rows.size() < expected && deadline.hasTimeLeft()) {
+ if (iterator.hasNext()) {
+ Row row = iterator.next();
+ LOG.info("collected row: {}", row);
+ rows.add(row);
+ } else {
+ Thread.sleep(500);
+ }
+ }
+ }
+ return rows;
+ }
+
+ private static List firstFieldStrings(List rows) {
+ List names = new ArrayList<>();
+ for (Row row : rows) {
+ Object first = row.getField(0);
+ if (first != null) {
+ names.add(first.toString());
+ }
+ }
+ return names;
+ }
+
+ private static Row findByOrderId(List rows, String orderId) {
+ for (Row row : rows) {
+ Object field = row.getField(0);
+ if (field != null && orderId.equals(field.toString())) {
+ return row;
+ }
+ }
+ return null;
+ }
+
+ /** Creates a Localstack Kinesis stream, waits until ACTIVE, and returns its ARN. */
+ private String createStream(String baseName) throws Exception {
+ String streamName = baseName + "_" + RUN_ID;
+ kinesisClient.createStream(
+ CreateStreamRequest.builder().streamName(streamName).shardCount(1).build());
+
+ Deadline deadline = Deadline.fromNow(Duration.ofMinutes(1));
+ while (!streamActive(streamName)) {
+ if (deadline.isOverdue()) {
+ throw new IllegalStateException("Stream did not become ACTIVE: " + streamName);
+ }
+ Thread.sleep(500);
+ }
+ return kinesisClient
+ .describeStream(DescribeStreamRequest.builder().streamName(streamName).build())
+ .streamDescription()
+ .streamARN();
+ }
+
+ private boolean streamActive(String streamName) {
+ try {
+ return kinesisClient
+ .describeStream(
+ DescribeStreamRequest.builder().streamName(streamName).build())
+ .streamDescription()
+ .streamStatus()
+ == StreamStatus.ACTIVE;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private String schemaName(String base) {
+ String name = "flink-avro-glue-sql-e2e-" + base + "-" + RUN_ID;
+ CREATED_SCHEMAS.add(name);
+ return name;
+ }
+
+ private static void deleteCreatedSchemas() {
+ if (CREATED_SCHEMAS.isEmpty()) {
+ return;
+ }
+ try (GlueClient glue =
+ GlueClient.builder()
+ .region(Region.of(GSR_REGION))
+ .credentialsProvider(
+ StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
+ .build()) {
+ for (String schema : CREATED_SCHEMAS) {
+ try {
+ glue.deleteSchema(
+ DeleteSchemaRequest.builder()
+ .schemaId(
+ SchemaId.builder()
+ .registryName(REGISTRY_NAME)
+ .schemaName(schema)
+ .build())
+ .build());
+ LOG.info("Deleted GSR schema {}", schema);
+ } catch (Exception e) {
+ LOG.warn(
+ "Best-effort delete of GSR schema {} failed: {}",
+ schema,
+ e.getMessage());
+ }
+ }
+ } catch (Exception e) {
+ LOG.warn("Could not create GlueClient for schema cleanup: {}", e.getMessage());
+ }
+ }
+
+ private static String envOrDefault(String name, String defaultValue) {
+ String value = System.getenv(name);
+ return (value == null || value.isEmpty()) ? defaultValue : value;
+ }
+}
diff --git a/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/resources/aws-e2e-setup.sh b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/resources/aws-e2e-setup.sh
new file mode 100755
index 000000000..f3ed9fa3e
--- /dev/null
+++ b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/resources/aws-e2e-setup.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Setup and teardown AWS resources for avro-glue E2E tests.
+#
+# Usage:
+# ./aws-e2e-setup.sh setup # Create Kinesis stream + GSR registry
+# ./aws-e2e-setup.sh teardown # Delete Kinesis stream + GSR registry
+#
+# Environment variables (set before running):
+# AWS_REGION - AWS region (default: us-east-1)
+# KINESIS_STREAM - Kinesis stream name (default: flink-avro-glue-e2e-test)
+# GSR_REGISTRY_NAME - GSR registry name (default: flink-avro-glue-e2e-registry)
+# GSR_SCHEMA_NAME - Schema name prefix (default: flink-avro-glue-e2e-schema)
+
+set -euo pipefail
+
+AWS_REGION="${AWS_REGION:-us-east-1}"
+KINESIS_STREAM="${KINESIS_STREAM:-flink-avro-glue-e2e-test}"
+GSR_REGISTRY_NAME="${GSR_REGISTRY_NAME:-flink-avro-glue-e2e-registry}"
+GSR_SCHEMA_NAME="${GSR_SCHEMA_NAME:-flink-avro-glue-e2e-schema}"
+
+setup() {
+ echo "=== Creating Kinesis stream: ${KINESIS_STREAM} ==="
+ aws kinesis create-stream \
+ --stream-name "${KINESIS_STREAM}" \
+ --shard-count 1 \
+ --region "${AWS_REGION}" 2>/dev/null || echo "Stream may already exist"
+
+ echo "Waiting for stream to become ACTIVE..."
+ aws kinesis wait stream-exists \
+ --stream-name "${KINESIS_STREAM}" \
+ --region "${AWS_REGION}"
+
+ STREAM_ARN=$(aws kinesis describe-stream-summary \
+ --stream-name "${KINESIS_STREAM}" \
+ --region "${AWS_REGION}" \
+ --query 'StreamDescriptionSummary.StreamARN' \
+ --output text)
+
+ echo "=== Creating GSR registry: ${GSR_REGISTRY_NAME} ==="
+ aws glue create-registry \
+ --registry-name "${GSR_REGISTRY_NAME}" \
+ --region "${AWS_REGION}" 2>/dev/null || echo "Registry may already exist"
+
+ echo ""
+ echo "=== Setup complete ==="
+ echo "Export these before running the E2E test:"
+ echo " export AWS_REGION=${AWS_REGION}"
+ echo " export KINESIS_STREAM_ARN=${STREAM_ARN}"
+ echo " export GSR_REGISTRY_NAME=${GSR_REGISTRY_NAME}"
+ echo " export GSR_SCHEMA_NAME=${GSR_SCHEMA_NAME}"
+}
+
+teardown() {
+ echo "=== Deleting Kinesis stream: ${KINESIS_STREAM} ==="
+ aws kinesis delete-stream \
+ --stream-name "${KINESIS_STREAM}" \
+ --enforce-consumer-deletion \
+ --region "${AWS_REGION}" 2>/dev/null || echo "Stream may not exist"
+
+ echo "=== Deleting GSR schemas in registry: ${GSR_REGISTRY_NAME} ==="
+ # Delete all schemas in the registry before deleting the registry
+ SCHEMAS=$(aws glue list-schemas \
+ --registry-id RegistryName="${GSR_REGISTRY_NAME}" \
+ --region "${AWS_REGION}" \
+ --query 'Schemas[].SchemaName' \
+ --output text 2>/dev/null || echo "")
+
+ for schema in ${SCHEMAS}; do
+ echo " Deleting schema: ${schema}"
+ aws glue delete-schema \
+ --schema-id SchemaName="${schema}",RegistryName="${GSR_REGISTRY_NAME}" \
+ --region "${AWS_REGION}" 2>/dev/null || true
+ done
+
+ echo "=== Deleting GSR registry: ${GSR_REGISTRY_NAME} ==="
+ aws glue delete-registry \
+ --registry-id RegistryName="${GSR_REGISTRY_NAME}" \
+ --region "${AWS_REGION}" 2>/dev/null || echo "Registry may not exist"
+
+ echo "=== Teardown complete ==="
+}
+
+case "${1:-}" in
+ setup)
+ setup
+ ;;
+ teardown)
+ teardown
+ ;;
+ *)
+ echo "Usage: $0 {setup|teardown}"
+ exit 1
+ ;;
+esac
diff --git a/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/resources/log4j2-test.properties b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/resources/log4j2-test.properties
index 835c2ec9a..e0c3649c8 100644
--- a/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/resources/log4j2-test.properties
+++ b/flink-connector-aws-e2e-tests/flink-formats-avro-glue-schema-registry-e2e-tests/src/test/resources/log4j2-test.properties
@@ -26,3 +26,13 @@ appender.testlogger.type = CONSOLE
appender.testlogger.target = SYSTEM_ERR
appender.testlogger.layout.type = PatternLayout
appender.testlogger.layout.pattern = %-4r [%t] %-5p %c %x - %m%n
+
+# GSR format classes at INFO (for debugging schema fetcher)
+logger.gsrformat.name = org.apache.flink.formats.avro.glue.schema.registry
+logger.gsrformat.level = INFO
+logger.gsrformat.appenderRef.test.ref = TestLogger
+
+# E2E test class
+logger.e2e.name = org.apache.flink.glue.schema.registry.test
+logger.e2e.level = INFO
+logger.e2e.appenderRef.test.ref = TestLogger
diff --git a/flink-connector-aws-e2e-tests/flink-formats-protobuf-glue-schema-registry-e2e-tests/pom.xml b/flink-connector-aws-e2e-tests/flink-formats-protobuf-glue-schema-registry-e2e-tests/pom.xml
new file mode 100644
index 000000000..6741e4dd9
--- /dev/null
+++ b/flink-connector-aws-e2e-tests/flink-formats-protobuf-glue-schema-registry-e2e-tests/pom.xml
@@ -0,0 +1,165 @@
+
+
+
+ 4.0.0
+
+
+ org.apache.flink
+ flink-connector-aws-e2e-tests-parent
+ 6.0-SNAPSHOT
+
+
+ flink-formats-protobuf-glue-schema-registry-e2e-tests
+ Flink : Formats : AWS : E2E Tests : Protobuf Glue Schema Registry
+ jar
+
+
+
+
+ org.apache.flink
+ flink-protobuf-glue-schema-registry
+ ${project.version}
+ test
+
+
+
+
+ org.apache.flink
+ flink-connector-aws-kinesis-streams
+ ${project.version}
+ test
+
+
+
+
+ org.apache.flink
+ flink-connector-aws-base
+ ${project.version}
+ test-jar
+ test
+
+
+ org.apache.flink
+ flink-connector-aws-kinesis-streams
+ ${project.version}
+ test-jar
+ test
+
+
+
+
+ org.apache.flink
+ flink-table-api-java-bridge
+ ${flink.version}
+ test
+
+
+ org.apache.flink
+ flink-table-planner-loader
+ ${flink.version}
+ test
+
+
+ org.apache.flink
+ flink-table-runtime
+ ${flink.version}
+ test
+
+
+
+
+ org.apache.flink
+ flink-streaming-java
+ ${flink.version}
+ test
+
+
+ org.apache.flink
+ flink-clients
+ ${flink.version}
+ test
+
+
+
+
+ org.testcontainers
+ localstack
+ test
+
+
+
+
+ software.amazon.awssdk
+ kinesis
+ test
+
+
+ software.amazon.awssdk
+ s3
+ test
+
+
+ software.amazon.awssdk
+ glue
+ test
+
+
+ software.amazon.awssdk
+ sts
+ test
+
+
+ software.amazon.awssdk
+ netty-nio-client
+ test
+
+
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ 2.24.1
+ test
+
+
+ org.apache.logging.log4j
+ log4j-core
+ 2.24.1
+ test
+
+
+
+
+
+
+
+ com.google.errorprone
+ error_prone_annotations
+ 2.21.1
+
+
+
+
+
diff --git a/flink-connector-aws-e2e-tests/flink-formats-protobuf-glue-schema-registry-e2e-tests/src/test/java/org/apache/flink/glue/schema/registry/test/protobuf/GlueSchemaRegistryProtobufSqlKinesisITCase.java b/flink-connector-aws-e2e-tests/flink-formats-protobuf-glue-schema-registry-e2e-tests/src/test/java/org/apache/flink/glue/schema/registry/test/protobuf/GlueSchemaRegistryProtobufSqlKinesisITCase.java
new file mode 100644
index 000000000..13d5552ac
--- /dev/null
+++ b/flink-connector-aws-e2e-tests/flink-formats-protobuf-glue-schema-registry-e2e-tests/src/test/java/org/apache/flink/glue/schema/registry/test/protobuf/GlueSchemaRegistryProtobufSqlKinesisITCase.java
@@ -0,0 +1,555 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.glue.schema.registry.test.protobuf;
+
+import org.apache.flink.api.common.time.Deadline;
+import org.apache.flink.connector.aws.testutils.AWSServicesTestUtils;
+import org.apache.flink.connector.aws.testutils.LocalstackContainer;
+import org.apache.flink.connector.aws.util.AWSGeneralUtil;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
+import org.apache.flink.types.Row;
+import org.apache.flink.util.CloseableIterator;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.utility.DockerImageName;
+import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.glue.GlueClient;
+import software.amazon.awssdk.services.glue.model.DeleteSchemaRequest;
+import software.amazon.awssdk.services.glue.model.SchemaId;
+import software.amazon.awssdk.services.kinesis.KinesisClient;
+import software.amazon.awssdk.services.kinesis.model.CreateStreamRequest;
+import software.amazon.awssdk.services.kinesis.model.DescribeStreamRequest;
+import software.amazon.awssdk.services.kinesis.model.StreamStatus;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assumptions.assumeThat;
+
+/**
+ * SQL-path end-to-end test for the AWS Glue Schema Registry Protobuf format ({@code protobuf-glue})
+ * exercising the Flink Table API through a Localstack Kinesis data plane against a real Glue
+ * Schema Registry.
+ *
+ * The Kinesis connector talks to Localstack via an explicit {@code aws.endpoint} + dummy {@code
+ * BASIC} credentials, while the {@code protobuf-glue} format resolves the real GSR through the
+ * default AWS credential chain, which we seed with the {@code IT_CASE_GLUE_SCHEMA_*} credentials
+ * via JVM system properties in {@link #setup()} (the MiniCluster runs in this same JVM).
+ *
+ *
The test is tagged {@code requires-aws-credentials} so it only runs under the {@code
+ * run-aws-end-to-end-tests} Maven profile, and is additionally {@code assumeThat}-gated to skip
+ * when credentials are absent.
+ *
+ *
Scenarios:
+ *
+ *
+ * - {@link #testBasicMultiRowRoundTrip()} — multi-row round-trip of STRING/INT/BOOLEAN columns.
+ *
- {@link #testNullableColumnsRoundTrip()} — nullable columns carrying explicit {@code NULL}
+ * values, exercising the proto3 explicit-presence fix (C2).
+ *
- {@link #testCompressionRoundTrip()} — same round-trip with {@code
+ * protobuf-glue.schema.compression = ZLIB}, exercising the compression round-trip fix (C1).
+ *
- {@link #existingSchemaWithoutAutoRegistration()} — a pre-existing schema is written to by a
+ * second table with {@code autoRegistration = false}, which must succeed because no
+ * registration is required.
+ *
- {@link #missingSchemaWithoutAutoRegistrationFails()} — writing against a never-registered
+ * schema with {@code autoRegistration = false} must fail.
+ *
+ */
+@Tag("requires-aws-credentials")
+class GlueSchemaRegistryProtobufSqlKinesisITCase {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(GlueSchemaRegistryProtobufSqlKinesisITCase.class);
+
+ private static final String ACCESS_KEY = System.getenv("IT_CASE_GLUE_SCHEMA_ACCESS_KEY");
+ private static final String SECRET_KEY = System.getenv("IT_CASE_GLUE_SCHEMA_SECRET_KEY");
+ private static final String GSR_REGION =
+ envOrDefault("IT_CASE_GLUE_SCHEMA_REGION", "ca-central-1");
+ private static final String REGISTRY_NAME =
+ envOrDefault("IT_CASE_GLUE_SCHEMA_REGISTRY_NAME", "default-registry");
+
+ private static final String LOCALSTACK_DOCKER_IMAGE_VERSION = "localstack/localstack:3.7.2";
+ private static final String KINESIS_REGION = "ap-southeast-1";
+ private static final String KINESIS_ACCOUNT = "000000000000";
+
+ private static final LocalstackContainer MOCK_KINESIS_CONTAINER =
+ new LocalstackContainer(DockerImageName.parse(LOCALSTACK_DOCKER_IMAGE_VERSION))
+ .withNetworkAliases("localstack");
+
+ private SdkHttpClient httpClient;
+ private KinesisClient kinesisClient;
+ private StreamTableEnvironment tEnv;
+ private final List createdSchemas = new ArrayList<>();
+
+ @BeforeAll
+ static void beforeAll() {
+ assumeThat(ACCESS_KEY)
+ .as("IT_CASE_GLUE_SCHEMA_ACCESS_KEY must be set to run this test")
+ .isNotBlank();
+ assumeThat(SECRET_KEY)
+ .as("IT_CASE_GLUE_SCHEMA_SECRET_KEY must be set to run this test")
+ .isNotBlank();
+
+ System.setProperty(SdkSystemSetting.CBOR_ENABLED.property(), "false");
+ MOCK_KINESIS_CONTAINER.start();
+ }
+
+ @AfterAll
+ static void afterAll() {
+ if (MOCK_KINESIS_CONTAINER.isRunning()) {
+ MOCK_KINESIS_CONTAINER.stop();
+ }
+ System.clearProperty(SdkSystemSetting.CBOR_ENABLED.property());
+ }
+
+ @BeforeEach
+ void setup() {
+ // Seed the default AWS credential chain so the protobuf-glue format authenticates against
+ // the real Glue Schema Registry from inside the MiniCluster JVM.
+ System.setProperty("aws.accessKeyId", ACCESS_KEY);
+ System.setProperty("aws.secretAccessKey", SECRET_KEY);
+ System.setProperty("aws.region", GSR_REGION);
+
+ httpClient = AWSServicesTestUtils.createHttpClient();
+ kinesisClient =
+ AWSServicesTestUtils.createAwsSyncClient(
+ MOCK_KINESIS_CONTAINER.getEndpoint(), httpClient, KinesisClient.builder());
+
+ StreamExecutionEnvironment execEnv = StreamExecutionEnvironment.getExecutionEnvironment();
+ execEnv.setParallelism(1);
+ tEnv = StreamTableEnvironment.create(execEnv);
+
+ LOG.info("Done setting up Localstack Kinesis + real GSR credential chain.");
+ }
+
+ @AfterEach
+ void teardown() {
+ cleanupSchemas();
+ AWSGeneralUtil.closeResources(httpClient, kinesisClient);
+ System.clearProperty("aws.accessKeyId");
+ System.clearProperty("aws.secretAccessKey");
+ System.clearProperty("aws.region");
+ }
+
+ @Test
+ void testBasicMultiRowRoundTrip() throws Exception {
+ String id = uniqueId("basic");
+ String schemaName = schemaName(id);
+ prepareStream(id);
+
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_sink_basic ("
+ + " user_name STRING,"
+ + " age INT,"
+ + " is_active BOOLEAN"
+ + ") WITH ("
+ + kinesisOptions(id, false)
+ + ","
+ + protobufGlueOptions(schemaName, true, null)
+ + ")");
+
+ tEnv.executeSql(
+ "INSERT INTO kinesis_sink_basic VALUES "
+ + "('Alice', 30, true),"
+ + "('Bob', 25, false),"
+ + "('Charlie', 35, true)")
+ .await(120, TimeUnit.SECONDS);
+
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_source_basic ("
+ + " user_name STRING,"
+ + " age INT,"
+ + " is_active BOOLEAN"
+ + ") WITH ("
+ + kinesisOptions(id, true)
+ + ","
+ + protobufGlueOptions(schemaName, false, null)
+ + ")");
+
+ List rows = collect("SELECT * FROM kinesis_source_basic", 3, Duration.ofSeconds(90));
+
+ assertThat(rows).hasSize(3);
+ assertThat(rows)
+ .extracting(row -> String.valueOf(row.getField(0)))
+ .containsExactlyInAnyOrder("Alice", "Bob", "Charlie");
+ }
+
+ @Test
+ void testNullableColumnsRoundTrip() throws Exception {
+ String id = uniqueId("nullable");
+ String schemaName = schemaName(id);
+ prepareStream(id);
+
+ // All value columns are nullable (default in Flink SQL) so the proto3 explicit-presence
+ // fix (C2) must round-trip explicit NULLs as NULL rather than proto3 defaults.
+ String columns =
+ " id STRING," + " opt_str STRING," + " opt_int INT," + " opt_bool BOOLEAN";
+
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_sink_nullable ("
+ + columns
+ + ") WITH ("
+ + kinesisOptions(id, false)
+ + ","
+ + protobufGlueOptions(schemaName, true, null)
+ + ")");
+
+ tEnv.executeSql(
+ "INSERT INTO kinesis_sink_nullable VALUES "
+ + "('R1', 'hello', 42, true),"
+ + "('R2', CAST(NULL AS STRING), CAST(NULL AS INT), CAST(NULL AS BOOLEAN))")
+ .await(120, TimeUnit.SECONDS);
+
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_source_nullable ("
+ + columns
+ + ") WITH ("
+ + kinesisOptions(id, true)
+ + ","
+ + protobufGlueOptions(schemaName, false, null)
+ + ")");
+
+ List rows =
+ collect("SELECT * FROM kinesis_source_nullable", 2, Duration.ofSeconds(90));
+
+ assertThat(rows).hasSize(2);
+
+ Row r1 = findById(rows, "R1");
+ assertThat(r1.getField(1)).isEqualTo("hello");
+ assertThat(r1.getField(2)).isEqualTo(42);
+ assertThat(r1.getField(3)).isEqualTo(true);
+
+ Row r2 = findById(rows, "R2");
+ assertThat(r2.getField(1)).as("nullable STRING should round-trip as NULL").isNull();
+ assertThat(r2.getField(2)).as("nullable INT should round-trip as NULL").isNull();
+ assertThat(r2.getField(3)).as("nullable BOOLEAN should round-trip as NULL").isNull();
+ }
+
+ @Test
+ void testCompressionRoundTrip() throws Exception {
+ String id = uniqueId("compression");
+ String schemaName = schemaName(id);
+ prepareStream(id);
+
+ // 'schema.compression' = 'ZLIB' exercises the compression round-trip fix (C1): the reader
+ // must transparently decompress GSR-compressed payloads.
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_sink_zlib ("
+ + " user_name STRING,"
+ + " age INT,"
+ + " is_active BOOLEAN"
+ + ") WITH ("
+ + kinesisOptions(id, false)
+ + ","
+ + protobufGlueOptions(schemaName, true, "ZLIB")
+ + ")");
+
+ tEnv.executeSql(
+ "INSERT INTO kinesis_sink_zlib VALUES "
+ + "('Dave', 40, true),"
+ + "('Eve', 28, false),"
+ + "('Frank', 33, true)")
+ .await(120, TimeUnit.SECONDS);
+
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_source_zlib ("
+ + " user_name STRING,"
+ + " age INT,"
+ + " is_active BOOLEAN"
+ + ") WITH ("
+ + kinesisOptions(id, true)
+ + ","
+ + protobufGlueOptions(schemaName, false, "ZLIB")
+ + ")");
+
+ List rows = collect("SELECT * FROM kinesis_source_zlib", 3, Duration.ofSeconds(90));
+
+ assertThat(rows).hasSize(3);
+ assertThat(rows)
+ .extracting(row -> String.valueOf(row.getField(0)))
+ .containsExactlyInAnyOrder("Dave", "Eve", "Frank");
+ }
+
+ @Test
+ void existingSchemaWithoutAutoRegistration() throws Exception {
+ String id = uniqueId("existing");
+ String schemaName = schemaName(id);
+ prepareStream(id);
+
+ // Phase A: register the schema (and its first version) in real GSR through a sink with
+ // autoRegistration = true, landing two rows on the stream.
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_sink_existing_a ("
+ + " user_name STRING,"
+ + " age INT,"
+ + " is_active BOOLEAN"
+ + ") WITH ("
+ + kinesisOptions(id, false)
+ + ","
+ + protobufGlueOptionsExplicitAutoReg(schemaName, true)
+ + ")");
+
+ tEnv.executeSql(
+ "INSERT INTO kinesis_sink_existing_a VALUES "
+ + "('Alice', 30, true),"
+ + "('Bob', 25, false)")
+ .await(120, TimeUnit.SECONDS);
+
+ // Phase B: a second sink at the SAME schema name with autoRegistration = false must succeed
+ // because the schema already exists in GSR — no registration is attempted.
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_sink_existing_b ("
+ + " user_name STRING,"
+ + " age INT,"
+ + " is_active BOOLEAN"
+ + ") WITH ("
+ + kinesisOptions(id, false)
+ + ","
+ + protobufGlueOptionsExplicitAutoReg(schemaName, false)
+ + ")");
+
+ tEnv.executeSql(
+ "INSERT INTO kinesis_sink_existing_b VALUES "
+ + "('Charlie', 35, true),"
+ + "('Dave', 40, false)")
+ .await(120, TimeUnit.SECONDS);
+
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_source_existing ("
+ + " user_name STRING,"
+ + " age INT,"
+ + " is_active BOOLEAN"
+ + ") WITH ("
+ + kinesisOptions(id, true)
+ + ","
+ + protobufGlueOptionsExplicitAutoReg(schemaName, false)
+ + ")");
+
+ List rows =
+ collect("SELECT * FROM kinesis_source_existing", 4, Duration.ofSeconds(90));
+
+ assertThat(rows)
+ .as(
+ "all four rows (two written under autoRegistration=true, two under "
+ + "autoRegistration=false against the pre-existing schema) must arrive")
+ .hasSize(4);
+ assertThat(rows)
+ .extracting(row -> String.valueOf(row.getField(0)))
+ .containsExactlyInAnyOrder("Alice", "Bob", "Charlie", "Dave");
+ }
+
+ @Test
+ void missingSchemaWithoutAutoRegistrationFails() throws Exception {
+ String id = uniqueId("missing");
+ String schemaName = schemaName(id);
+ prepareStream(id);
+
+ // The schema name is never registered in GSR and autoRegistration is disabled, so the sink
+ // has no schema to serialize against and the INSERT job must fail.
+ tEnv.executeSql(
+ "CREATE TABLE kinesis_sink_missing ("
+ + " user_name STRING,"
+ + " age INT,"
+ + " is_active BOOLEAN"
+ + ") WITH ("
+ + kinesisOptions(id, false)
+ + ","
+ + protobufGlueOptionsExplicitAutoReg(schemaName, false)
+ + ")");
+
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ "INSERT INTO kinesis_sink_missing VALUES "
+ + "('Alice', 30, true)")
+ .await(120, TimeUnit.SECONDS))
+ .as(
+ "writing against a schema that does not exist in GSR with "
+ + "autoRegistration=false must fail rather than silently registering it")
+ .isInstanceOf(Exception.class);
+ }
+
+ private List collect(String selectSql, int expected, Duration timeout) throws Exception {
+ List rows = new ArrayList<>();
+ try (CloseableIterator iterator = tEnv.executeSql(selectSql).collect()) {
+ Deadline deadline = Deadline.fromNow(timeout);
+ while (rows.size() < expected && deadline.hasTimeLeft()) {
+ if (iterator.hasNext()) {
+ Row row = iterator.next();
+ LOG.info("collected row: {}", row);
+ rows.add(row);
+ } else {
+ Thread.sleep(500);
+ }
+ }
+ }
+ return rows;
+ }
+
+ private Row findById(List rows, String id) {
+ for (Row row : rows) {
+ if (id.equals(String.valueOf(row.getField(0)))) {
+ return row;
+ }
+ }
+ throw new AssertionError("Row with id '" + id + "' not found in " + rows);
+ }
+
+ private String kinesisOptions(String streamName, boolean source) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(" 'connector' = 'kinesis',");
+ sb.append(" 'stream.arn' = '").append(streamArn(streamName)).append("',");
+ sb.append(" 'aws.region' = '").append(KINESIS_REGION).append("',");
+ sb.append(" 'aws.endpoint' = '").append(MOCK_KINESIS_CONTAINER.getEndpoint()).append("',");
+ sb.append(" 'aws.credentials.provider' = 'BASIC',");
+ sb.append(" 'aws.credentials.basic.accesskeyid' = 'accessKeyId',");
+ sb.append(" 'aws.credentials.basic.secretkey' = 'secretAccessKey',");
+ sb.append(" 'aws.trust.all.certificates' = 'true',");
+ sb.append(" 'aws.http.protocol.version' = 'HTTP1_1'");
+ if (source) {
+ sb.append(", 'source.init.position' = 'TRIM_HORIZON'");
+ }
+ return sb.toString();
+ }
+
+ private String protobufGlueOptions(String schemaName, boolean forSink, String compression) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(" 'format' = 'protobuf-glue',");
+ sb.append(" 'protobuf-glue.aws.region' = '").append(GSR_REGION).append("',");
+ sb.append(" 'protobuf-glue.registry.name' = '").append(REGISTRY_NAME).append("',");
+ sb.append(" 'protobuf-glue.schema.name' = '").append(schemaName).append("'");
+ if (forSink) {
+ sb.append(", 'protobuf-glue.schema.autoRegistration' = 'true'");
+ }
+ if (compression != null) {
+ sb.append(", 'protobuf-glue.schema.compression' = '").append(compression).append("'");
+ }
+ return sb.toString();
+ }
+
+ private String protobufGlueOptionsExplicitAutoReg(String schemaName, boolean autoRegistration) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(" 'format' = 'protobuf-glue',");
+ sb.append(" 'protobuf-glue.aws.region' = '").append(GSR_REGION).append("',");
+ sb.append(" 'protobuf-glue.registry.name' = '").append(REGISTRY_NAME).append("',");
+ sb.append(" 'protobuf-glue.schema.name' = '").append(schemaName).append("',");
+ sb.append(" 'protobuf-glue.schema.autoRegistration' = '")
+ .append(autoRegistration)
+ .append("'");
+ return sb.toString();
+ }
+
+ private void prepareStream(String streamName) throws Exception {
+ kinesisClient.createStream(
+ CreateStreamRequest.builder().streamName(streamName).shardCount(1).build());
+
+ Deadline deadline = Deadline.fromNow(Duration.ofMinutes(1));
+ while (deadline.hasTimeLeft()) {
+ if (streamActive(streamName)) {
+ return;
+ }
+ Thread.sleep(500);
+ }
+ throw new IllegalStateException("Stream " + streamName + " did not become ACTIVE in time");
+ }
+
+ private boolean streamActive(String streamName) {
+ try {
+ return kinesisClient
+ .describeStream(
+ DescribeStreamRequest.builder().streamName(streamName).build())
+ .streamDescription()
+ .streamStatus()
+ == StreamStatus.ACTIVE;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private void cleanupSchemas() {
+ if (createdSchemas.isEmpty()) {
+ return;
+ }
+ try (SdkHttpClient glueHttpClient = AWSServicesTestUtils.createHttpClient();
+ GlueClient glueClient =
+ GlueClient.builder()
+ .region(Region.of(GSR_REGION))
+ .httpClient(glueHttpClient)
+ .build()) {
+ for (String schemaName : createdSchemas) {
+ try {
+ glueClient.deleteSchema(
+ DeleteSchemaRequest.builder()
+ .schemaId(
+ SchemaId.builder()
+ .registryName(REGISTRY_NAME)
+ .schemaName(schemaName)
+ .build())
+ .build());
+ LOG.info("Deleted GSR schema {}", schemaName);
+ } catch (Exception e) {
+ LOG.warn(
+ "Best-effort cleanup failed for schema {}: {}",
+ schemaName,
+ e.getMessage());
+ }
+ }
+ } catch (Exception e) {
+ LOG.warn("Best-effort GSR schema cleanup skipped: {}", e.getMessage());
+ }
+ createdSchemas.clear();
+ }
+
+ private String uniqueId(String scenario) {
+ return "gsr_pb_sql_" + scenario + "_" + Long.toHexString(System.nanoTime());
+ }
+
+ private String schemaName(String id) {
+ String schemaName = "flink-protobuf-glue-e2e-" + id;
+ createdSchemas.add(schemaName);
+ return schemaName;
+ }
+
+ private String streamArn(String streamName) {
+ return "arn:aws:kinesis:"
+ + KINESIS_REGION
+ + ":"
+ + KINESIS_ACCOUNT
+ + ":stream/"
+ + streamName;
+ }
+
+ private static String envOrDefault(String name, String defaultValue) {
+ String value = System.getenv(name);
+ return (value == null || value.trim().isEmpty()) ? defaultValue : value;
+ }
+}
diff --git a/flink-connector-aws-e2e-tests/flink-formats-protobuf-glue-schema-registry-e2e-tests/src/test/resources/log4j2-test.properties b/flink-connector-aws-e2e-tests/flink-formats-protobuf-glue-schema-registry-e2e-tests/src/test/resources/log4j2-test.properties
new file mode 100644
index 000000000..bc8557fd3
--- /dev/null
+++ b/flink-connector-aws-e2e-tests/flink-formats-protobuf-glue-schema-registry-e2e-tests/src/test/resources/log4j2-test.properties
@@ -0,0 +1,38 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+# Set root logger level to OFF to not flood build logs
+# set manually to INFO for debugging purposes
+rootLogger.level = OFF
+rootLogger.appenderRef.test.ref = TestLogger
+
+appender.testlogger.name = TestLogger
+appender.testlogger.type = CONSOLE
+appender.testlogger.target = SYSTEM_ERR
+appender.testlogger.layout.type = PatternLayout
+appender.testlogger.layout.pattern = %-4r [%t] %-5p %c %x - %m%n
+
+# GSR protobuf format classes at INFO (for debugging schema fetcher)
+logger.gsrformat.name = org.apache.flink.formats.protobuf.glue.schema.registry
+logger.gsrformat.level = INFO
+logger.gsrformat.appenderRef.test.ref = TestLogger
+
+# E2E test class
+logger.e2e.name = org.apache.flink.glue.schema.registry.test.protobuf
+logger.e2e.level = INFO
+logger.e2e.appenderRef.test.ref = TestLogger
diff --git a/flink-connector-aws-e2e-tests/pom.xml b/flink-connector-aws-e2e-tests/pom.xml
index 0d9b466f7..55c90390c 100644
--- a/flink-connector-aws-e2e-tests/pom.xml
+++ b/flink-connector-aws-e2e-tests/pom.xml
@@ -44,6 +44,7 @@ under the License.
flink-connector-aws-sqs-e2e-tests
flink-formats-avro-glue-schema-registry-e2e-tests
flink-formats-json-glue-schema-registry-e2e-tests
+ flink-formats-protobuf-glue-schema-registry-e2e-tests
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/pom.xml b/flink-formats-aws/flink-avro-glue-schema-registry/pom.xml
index 4bfe40885..da50e6a8c 100644
--- a/flink-formats-aws/flink-avro-glue-schema-registry/pom.xml
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/pom.xml
@@ -39,6 +39,18 @@ under the License.
${flink.version}
provided
+
+ org.apache.flink
+ flink-table-api-java
+ ${flink.version}
+ provided
+
+
+ org.apache.flink
+ flink-table-common
+ ${flink.version}
+ provided
+
org.apache.flink
flink-avro
@@ -61,12 +73,60 @@ under the License.
${glue.schema.registry.version}
-
+
+
+ org.apache.flink
+ flink-table-runtime
+ ${flink.version}
+ test
+
+
+ org.apache.flink
+ flink-table-api-java
+ ${flink.version}
+ test
+ test-jar
+
+
+ org.apache.flink
+ flink-table-common
+ ${flink.version}
+ test
+ test-jar
+
+
+ org.apache.flink
+ flink-avro
+ ${flink.version}
+ test
+ test-jar
+
+
org.apache.flink
flink-architecture-tests-test
test
+
+
+
+ net.jqwik
+ jqwik
+ 1.8.2
+ test
+
+
+
+
+
+
+ org.opentest4j
+ opentest4j
+ 1.3.0
+
+
+
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/AvroGlueFormatOptions.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/AvroGlueFormatOptions.java
new file mode 100644
index 000000000..f889f803c
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/AvroGlueFormatOptions.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.annotation.PublicEvolving;
+import org.apache.flink.configuration.ConfigOption;
+import org.apache.flink.configuration.ConfigOptions;
+
+import com.amazonaws.services.schemaregistry.utils.AvroRecordType;
+
+/**
+ * Avro-specific configuration options for the AWS Glue Schema Registry Avro format factory.
+ *
+ * Shared options (aws.region, registry.name, schema.name, etc.) are defined in {@link
+ * GlueFormatOptions}.
+ */
+@PublicEvolving
+public class AvroGlueFormatOptions extends GlueFormatOptions {
+
+ public static final ConfigOption SCHEMA_TYPE =
+ ConfigOptions.key("schema.type")
+ .enumType(AvroRecordType.class)
+ .defaultValue(AvroRecordType.GENERIC_RECORD)
+ .withDescription("Avro record type. Defaults to GENERIC_RECORD.");
+
+ public static final ConfigOption AVRO_NAMESPACE =
+ ConfigOptions.key("avro.namespace")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Override the namespace in the auto-generated Avro schema. "
+ + "Use this to match schemas already registered in GSR with a different namespace.");
+
+ public static final ConfigOption AVRO_RECORD_NAME =
+ ConfigOptions.key("avro.record-name")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Override the record name in the auto-generated Avro schema. "
+ + "Use this to match schemas already registered in GSR with a different record name.");
+
+ public static final ConfigOption SCHEMA_FETCH_FROM_REGISTRY =
+ ConfigOptions.key("schema.fetchFromRegistry")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Whether to fetch the schema from GSR instead of using the auto-generated one. "
+ + "Defaults to false.");
+
+ private AvroGlueFormatOptions() {}
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaPatcher.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaPatcher.java
new file mode 100644
index 000000000..450d93894
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaPatcher.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.annotation.Internal;
+
+import org.apache.avro.Schema;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Utility class that creates a new Avro {@link Schema} with overridden namespace and/or record name
+ * while preserving all field definitions from the original schema.
+ *
+ * This addresses the Avro schema namespace bug where Flink's {@code AvroSchemaConverter}
+ * auto-generates a namespace (e.g. {@code org.apache.flink.avro.generated}) that may differ from
+ * schemas already registered in AWS Glue Schema Registry.
+ *
+ *
The patcher recursively patches all nested record types to use the same namespace, ensuring
+ * consistent namespace usage throughout the schema hierarchy.
+ */
+@Internal
+public class AvroSchemaPatcher {
+
+ /**
+ * Creates a new Avro Schema with overridden namespace and/or record name, preserving all field
+ * definitions from the original schema. Recursively patches all nested record types to use the
+ * same namespace.
+ *
+ * @param original the original Avro schema (can be a RECORD or UNION type)
+ * @param namespace the namespace override, or {@code null} to keep the original namespace
+ * @param recordName the record name override, or {@code null} to keep the original record name
+ * @return a new Schema with the overridden namespace/name and the same fields as the original
+ */
+ public static Schema patchSchema(
+ Schema original, @Nullable String namespace, @Nullable String recordName) {
+ if (namespace == null && recordName == null) {
+ return original;
+ }
+
+ // Track already-patched schemas to handle recursive references
+ Map patchedSchemas = new HashMap<>();
+
+ // Special handling for top-level UNION: apply recordName to the main RECORD in the union
+ if (original.getType() == Schema.Type.UNION) {
+ return patchTopLevelUnion(original, namespace, recordName, patchedSchemas);
+ }
+
+ return patchSchemaRecursive(original, namespace, recordName, patchedSchemas);
+ }
+
+ /**
+ * Patches a top-level UNION schema, applying the recordName override to the main RECORD type.
+ * This handles the common case where AvroSchemaConverter produces ["null", record] unions.
+ */
+ private static Schema patchTopLevelUnion(
+ Schema unionSchema,
+ @Nullable String namespace,
+ @Nullable String recordName,
+ Map patchedSchemas) {
+
+ List patchedTypes = new ArrayList<>();
+ boolean recordNameApplied = false;
+
+ for (Schema unionType : unionSchema.getTypes()) {
+ if (unionType.getType() == Schema.Type.RECORD && !recordNameApplied) {
+ // Apply recordName to the first RECORD in the union
+ patchedTypes.add(
+ patchSchemaRecursive(unionType, namespace, recordName, patchedSchemas));
+ recordNameApplied = true;
+ } else {
+ // For other types (null, primitives, nested records), only apply namespace
+ patchedTypes.add(patchSchemaRecursive(unionType, namespace, null, patchedSchemas));
+ }
+ }
+ return Schema.createUnion(patchedTypes);
+ }
+
+ private static Schema patchSchemaRecursive(
+ Schema schema,
+ @Nullable String namespace,
+ @Nullable String recordName,
+ Map patchedSchemas) {
+
+ switch (schema.getType()) {
+ case RECORD:
+ return patchRecordSchema(schema, namespace, recordName, patchedSchemas);
+
+ case ARRAY:
+ Schema patchedElement =
+ patchSchemaRecursive(
+ schema.getElementType(), namespace, null, patchedSchemas);
+ return Schema.createArray(patchedElement);
+
+ case MAP:
+ Schema patchedValue =
+ patchSchemaRecursive(
+ schema.getValueType(), namespace, null, patchedSchemas);
+ return Schema.createMap(patchedValue);
+
+ case UNION:
+ List patchedTypes = new ArrayList<>();
+ for (Schema unionType : schema.getTypes()) {
+ patchedTypes.add(
+ patchSchemaRecursive(unionType, namespace, null, patchedSchemas));
+ }
+ return Schema.createUnion(patchedTypes);
+
+ default:
+ // Primitive types and other types don't need patching
+ return schema;
+ }
+ }
+
+ private static Schema patchRecordSchema(
+ Schema original,
+ @Nullable String namespace,
+ @Nullable String recordName,
+ Map patchedSchemas) {
+
+ String originalFullName = original.getFullName();
+
+ // Check if we've already patched this schema (handles recursive references)
+ if (patchedSchemas.containsKey(originalFullName)) {
+ return patchedSchemas.get(originalFullName);
+ }
+
+ String effectiveNamespace = namespace != null ? namespace : original.getNamespace();
+ String effectiveName = recordName != null ? recordName : original.getName();
+
+ // Create the new schema first (without fields) to handle recursive references
+ Schema patched =
+ Schema.createRecord(
+ effectiveName, original.getDoc(), effectiveNamespace, original.isError());
+
+ // Register before processing fields to handle self-references
+ patchedSchemas.put(originalFullName, patched);
+
+ // Now patch all fields recursively
+ List patchedFields =
+ original.getFields().stream()
+ .map(
+ f -> {
+ Schema patchedFieldSchema =
+ patchSchemaRecursive(
+ f.schema(), namespace, null, patchedSchemas);
+ return new Schema.Field(
+ f.name(), patchedFieldSchema, f.doc(), f.defaultVal());
+ })
+ .collect(Collectors.toList());
+
+ patched.setFields(patchedFields);
+ return patched;
+ }
+
+ private AvroSchemaPatcher() {}
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaResolver.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaResolver.java
new file mode 100644
index 000000000..4dbbc180c
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaResolver.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.configuration.ReadableConfig;
+
+import org.apache.avro.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+/**
+ * Resolves the Avro schema to use for GSR serialization/deserialization based on the configured
+ * options.
+ *
+ * Resolution flow:
+ *
+ *
+ * - If {@code schema.fetchFromRegistry} is true, attempt to fetch the schema from GSR
+ *
- If fetch fails or is disabled, check for {@code avro.namespace} / {@code avro.record-name}
+ * overrides and patch the auto-generated schema
+ *
- Otherwise, use the auto-generated schema unchanged
+ *
+ */
+@Internal
+public class AvroSchemaResolver {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AvroSchemaResolver.class);
+
+ /**
+ * Resolves the Avro schema based on the format options and the auto-generated schema.
+ *
+ * @param autoGeneratedSchema the Avro schema auto-generated from the Flink RowType via {@code
+ * AvroSchemaConverter.convertToSchema(RowType)}
+ * @param formatOptions the Flink format options from SQL DDL
+ * @param schemaFetcher optional callback to fetch schema from GSR; may be null if fetch is not
+ * supported
+ * @return the resolved Avro schema
+ */
+ public static Schema resolveSchema(
+ Schema autoGeneratedSchema,
+ ReadableConfig formatOptions,
+ @Nullable SchemaFetcher schemaFetcher) {
+
+ boolean fetchFromRegistry =
+ formatOptions.get(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY);
+
+ if (fetchFromRegistry && schemaFetcher != null) {
+ String schemaName = formatOptions.get(GlueFormatOptions.SCHEMA_NAME);
+ String registryName = formatOptions.get(GlueFormatOptions.REGISTRY_NAME);
+ try {
+ Schema fetched = schemaFetcher.fetchSchema(registryName, schemaName);
+ if (fetched != null) {
+ return fetched;
+ }
+ } catch (Exception e) {
+ LOG.warn(
+ "Failed to fetch schema '{}' from registry '{}', "
+ + "falling back to auto-generated schema.",
+ schemaName,
+ registryName,
+ e);
+ }
+ }
+
+ // Apply namespace/record-name patching if configured
+ String namespace =
+ formatOptions.getOptional(AvroGlueFormatOptions.AVRO_NAMESPACE).orElse(null);
+ String recordName =
+ formatOptions.getOptional(AvroGlueFormatOptions.AVRO_RECORD_NAME).orElse(null);
+
+ return AvroSchemaPatcher.patchSchema(autoGeneratedSchema, namespace, recordName);
+ }
+
+ /**
+ * Functional interface for fetching a schema from GSR. This allows the format factory to inject
+ * the actual GSR client call without coupling this resolver to the GSR SDK directly.
+ */
+ @FunctionalInterface
+ public interface SchemaFetcher {
+ /**
+ * Fetches the latest schema version from GSR.
+ *
+ * @param registryName the registry name
+ * @param schemaName the schema name
+ * @return the fetched Avro Schema, or null if not found
+ * @throws Exception if the fetch fails
+ */
+ @Nullable
+ Schema fetchSchema(String registryName, String schemaName) throws Exception;
+ }
+
+ private AvroSchemaResolver() {}
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueFormatConfigBuilder.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueFormatConfigBuilder.java
new file mode 100644
index 000000000..6df3dbc4d
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueFormatConfigBuilder.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.configuration.ReadableConfig;
+
+import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Utility class that builds the {@code Map} configuration required by the AWS Glue
+ * Schema Registry SDK from Flink's {@link ReadableConfig} format options.
+ */
+@Internal
+public class GlueFormatConfigBuilder {
+
+ /**
+ * Builds a GSR SDK configuration map from the shared {@link GlueFormatOptions}.
+ *
+ * @param formatOptions the Flink format options from SQL DDL
+ * @return a map of GSR SDK configuration keys to their values
+ */
+ public static Map buildConfigMap(ReadableConfig formatOptions) {
+ final Map properties = new HashMap<>();
+
+ formatOptions
+ .getOptional(GlueFormatOptions.AWS_REGION)
+ .ifPresent(v -> properties.put(AWSSchemaRegistryConstants.AWS_REGION, v));
+ formatOptions
+ .getOptional(GlueFormatOptions.AWS_ENDPOINT)
+ .ifPresent(v -> properties.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, v));
+ formatOptions
+ .getOptional(GlueFormatOptions.REGISTRY_NAME)
+ .ifPresent(v -> properties.put(AWSSchemaRegistryConstants.REGISTRY_NAME, v));
+ formatOptions
+ .getOptional(GlueFormatOptions.SCHEMA_NAME)
+ .ifPresent(v -> properties.put(AWSSchemaRegistryConstants.SCHEMA_NAME, v));
+ formatOptions
+ .getOptional(GlueFormatOptions.CACHE_SIZE)
+ .ifPresent(v -> properties.put(AWSSchemaRegistryConstants.CACHE_SIZE, v));
+ formatOptions
+ .getOptional(GlueFormatOptions.CACHE_TTL_MS)
+ .ifPresent(
+ v ->
+ properties.put(
+ AWSSchemaRegistryConstants.CACHE_TIME_TO_LIVE_MILLIS, v));
+ formatOptions
+ .getOptional(GlueFormatOptions.SCHEMA_AUTO_REGISTRATION)
+ .ifPresent(
+ v ->
+ properties.put(
+ AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING,
+ v));
+ formatOptions
+ .getOptional(GlueFormatOptions.SCHEMA_COMPATIBILITY)
+ .ifPresent(
+ v -> properties.put(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING, v));
+ formatOptions
+ .getOptional(GlueFormatOptions.SCHEMA_COMPRESSION)
+ .ifPresent(
+ v -> properties.put(AWSSchemaRegistryConstants.COMPRESSION_TYPE, v.name()));
+
+ return properties;
+ }
+
+ private GlueFormatConfigBuilder() {}
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueFormatOptions.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueFormatOptions.java
new file mode 100644
index 000000000..c9a2b7438
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueFormatOptions.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.annotation.PublicEvolving;
+import org.apache.flink.configuration.ConfigOption;
+import org.apache.flink.configuration.ConfigOptions;
+
+import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants;
+import software.amazon.awssdk.services.glue.model.Compatibility;
+
+import java.time.Duration;
+
+/**
+ * Base configuration options shared across all AWS Glue Schema Registry format factories (Avro,
+ * JSON, Protobuf).
+ */
+@PublicEvolving
+public class GlueFormatOptions {
+
+ public static final ConfigOption AWS_REGION =
+ ConfigOptions.key("aws.region")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("AWS region for the Glue Schema Registry.");
+
+ public static final ConfigOption AWS_ENDPOINT =
+ ConfigOptions.key("aws.endpoint")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("Custom AWS endpoint URL.");
+
+ public static final ConfigOption REGISTRY_NAME =
+ ConfigOptions.key("registry.name")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("Name of the Glue Schema Registry.");
+
+ public static final ConfigOption SCHEMA_NAME =
+ ConfigOptions.key("schema.name")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Schema name under which to register/look up the schema in Glue Schema Registry.");
+
+ public static final ConfigOption CACHE_SIZE =
+ ConfigOptions.key("cache.size")
+ .intType()
+ .defaultValue(200)
+ .withDescription(
+ "Maximum number of items in the schema cache. Defaults to 200.");
+
+ public static final ConfigOption CACHE_TTL_MS =
+ ConfigOptions.key("cache.ttlMs")
+ .longType()
+ .defaultValue(Duration.ofDays(1L).toMillis())
+ .withDescription("Cache TTL in milliseconds. Defaults to 1 day.");
+
+ public static final ConfigOption SCHEMA_AUTO_REGISTRATION =
+ ConfigOptions.key("schema.autoRegistration")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Whether to auto-register schemas with Glue Schema Registry. Defaults to false.");
+
+ public static final ConfigOption SCHEMA_COMPATIBILITY =
+ ConfigOptions.key("schema.compatibility")
+ .enumType(Compatibility.class)
+ .defaultValue(AWSSchemaRegistryConstants.DEFAULT_COMPATIBILITY_SETTING)
+ .withDescription("Schema compatibility mode for Glue Schema Registry.");
+
+ public static final ConfigOption SCHEMA_COMPRESSION =
+ ConfigOptions.key("schema.compression")
+ .enumType(AWSSchemaRegistryConstants.COMPRESSION.class)
+ .defaultValue(AWSSchemaRegistryConstants.COMPRESSION.NONE)
+ .withDescription("Compression type for schema data. Defaults to NONE.");
+
+ protected GlueFormatOptions() {}
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistryAvroFormatFactory.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistryAvroFormatFactory.java
new file mode 100644
index 000000000..1a324baf2
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistryAvroFormatFactory.java
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.serialization.DeserializationSchema;
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.configuration.ConfigOption;
+import org.apache.flink.configuration.ReadableConfig;
+import org.apache.flink.formats.avro.AvroRowDataDeserializationSchema;
+import org.apache.flink.formats.avro.AvroRowDataSerializationSchema;
+import org.apache.flink.formats.avro.AvroToRowDataConverters;
+import org.apache.flink.formats.avro.RowDataToAvroConverters;
+import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter;
+import org.apache.flink.table.connector.ChangelogMode;
+import org.apache.flink.table.connector.Projection;
+import org.apache.flink.table.connector.format.DecodingFormat;
+import org.apache.flink.table.connector.format.EncodingFormat;
+import org.apache.flink.table.connector.format.ProjectableDecodingFormat;
+import org.apache.flink.table.connector.sink.DynamicTableSink;
+import org.apache.flink.table.connector.source.DynamicTableSource;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.factories.DeserializationFormatFactory;
+import org.apache.flink.table.factories.DynamicTableFactory;
+import org.apache.flink.table.factories.FactoryUtil;
+import org.apache.flink.table.factories.SerializationFormatFactory;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.RowType;
+
+import org.apache.avro.Schema;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Table format factory for providing configured instances of AWS Glue Schema Registry Avro to
+ * RowData {@link SerializationSchema} and {@link DeserializationSchema}.
+ *
+ * This factory supports:
+ *
+ *
+ * - SPI discovery via identifier {@code avro-glue}
+ *
- Projection pushdown via {@link ProjectableDecodingFormat}
+ *
- Schema namespace/record-name patching via {@link AvroSchemaPatcher}
+ *
- Schema fetching from GSR via {@link AvroSchemaResolver}
+ *
+ */
+@Internal
+public class GlueSchemaRegistryAvroFormatFactory
+ implements DeserializationFormatFactory, SerializationFormatFactory {
+
+ public static final String IDENTIFIER = "avro-glue";
+
+ @Override
+ public DecodingFormat> createDecodingFormat(
+ DynamicTableFactory.Context context, ReadableConfig formatOptions) {
+ FactoryUtil.validateFactoryOptions(this, formatOptions);
+
+ return new ProjectableDecodingFormat>() {
+ @Override
+ public DeserializationSchema createRuntimeDecoder(
+ DynamicTableSource.Context context,
+ DataType producedDataType,
+ int[][] projections) {
+ producedDataType = Projection.of(projections).project(producedDataType);
+ final RowType rowType = (RowType) producedDataType.getLogicalType();
+ final TypeInformation rowDataTypeInfo =
+ context.createTypeInformation(producedDataType);
+ final Schema autoGeneratedSchema = AvroSchemaConverter.convertToSchema(rowType);
+ final Map configMap =
+ GlueFormatConfigBuilder.buildConfigMap(formatOptions);
+ final AvroSchemaResolver.SchemaFetcher schemaFetcher =
+ createSchemaFetcherIfEnabled(formatOptions, configMap);
+ final Schema resolvedSchema =
+ AvroSchemaResolver.resolveSchema(
+ autoGeneratedSchema, formatOptions, schemaFetcher);
+ return new AvroRowDataDeserializationSchema(
+ GlueSchemaRegistryAvroDeserializationSchema.forGeneric(
+ resolvedSchema, configMap),
+ AvroToRowDataConverters.createRowConverter(rowType),
+ rowDataTypeInfo);
+ }
+
+ @Override
+ public ChangelogMode getChangelogMode() {
+ return ChangelogMode.insertOnly();
+ }
+ };
+ }
+
+ @Override
+ public EncodingFormat> createEncodingFormat(
+ DynamicTableFactory.Context context, ReadableConfig formatOptions) {
+ FactoryUtil.validateFactoryOptions(this, formatOptions);
+
+ return new EncodingFormat>() {
+ @Override
+ public SerializationSchema createRuntimeEncoder(
+ DynamicTableSink.Context context, DataType consumedDataType) {
+ final RowType rowType = (RowType) consumedDataType.getLogicalType();
+ final Schema autoGeneratedSchema = AvroSchemaConverter.convertToSchema(rowType);
+ final Map configMap =
+ GlueFormatConfigBuilder.buildConfigMap(formatOptions);
+ final AvroSchemaResolver.SchemaFetcher schemaFetcher =
+ createSchemaFetcherIfEnabled(formatOptions, configMap);
+ final Schema resolvedSchema =
+ AvroSchemaResolver.resolveSchema(
+ autoGeneratedSchema, formatOptions, schemaFetcher);
+ final String transportName = formatOptions.get(GlueFormatOptions.SCHEMA_NAME);
+ return new AvroRowDataSerializationSchema(
+ rowType,
+ GlueSchemaRegistryAvroSerializationSchema.forGeneric(
+ resolvedSchema, transportName, configMap),
+ RowDataToAvroConverters.createConverter(rowType));
+ }
+
+ @Override
+ public ChangelogMode getChangelogMode() {
+ return ChangelogMode.insertOnly();
+ }
+ };
+ }
+
+ @Override
+ public String factoryIdentifier() {
+ return IDENTIFIER;
+ }
+
+ @Override
+ public Set> requiredOptions() {
+ Set> options = new HashSet<>();
+ options.add(GlueFormatOptions.AWS_REGION);
+ options.add(GlueFormatOptions.REGISTRY_NAME);
+ options.add(GlueFormatOptions.SCHEMA_NAME);
+ return options;
+ }
+
+ @Override
+ public Set> optionalOptions() {
+ Set> options = new HashSet<>();
+ options.add(GlueFormatOptions.AWS_ENDPOINT);
+ options.add(GlueFormatOptions.CACHE_SIZE);
+ options.add(GlueFormatOptions.CACHE_TTL_MS);
+ options.add(GlueFormatOptions.SCHEMA_AUTO_REGISTRATION);
+ options.add(GlueFormatOptions.SCHEMA_COMPATIBILITY);
+ options.add(GlueFormatOptions.SCHEMA_COMPRESSION);
+ options.add(AvroGlueFormatOptions.SCHEMA_TYPE);
+ options.add(AvroGlueFormatOptions.AVRO_NAMESPACE);
+ options.add(AvroGlueFormatOptions.AVRO_RECORD_NAME);
+ options.add(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY);
+ return options;
+ }
+
+ @Override
+ public Set> forwardOptions() {
+ return Stream.of(
+ GlueFormatOptions.AWS_REGION,
+ GlueFormatOptions.AWS_ENDPOINT,
+ GlueFormatOptions.REGISTRY_NAME,
+ GlueFormatOptions.SCHEMA_NAME,
+ GlueFormatOptions.CACHE_SIZE,
+ GlueFormatOptions.CACHE_TTL_MS,
+ GlueFormatOptions.SCHEMA_AUTO_REGISTRATION,
+ GlueFormatOptions.SCHEMA_COMPATIBILITY,
+ GlueFormatOptions.SCHEMA_COMPRESSION,
+ AvroGlueFormatOptions.SCHEMA_TYPE,
+ AvroGlueFormatOptions.AVRO_NAMESPACE,
+ AvroGlueFormatOptions.AVRO_RECORD_NAME,
+ AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY)
+ .collect(Collectors.toSet());
+ }
+
+ /**
+ * Creates a SchemaFetcher if fetchFromRegistry is enabled.
+ *
+ * @param formatOptions the format options
+ * @param configMap the GSR config map
+ * @return a SchemaFetcher instance, or null if fetchFromRegistry is disabled
+ */
+ private static AvroSchemaResolver.SchemaFetcher createSchemaFetcherIfEnabled(
+ ReadableConfig formatOptions, Map configMap) {
+ boolean fetchFromRegistry =
+ formatOptions.get(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY);
+ if (fetchFromRegistry) {
+ return new GlueSchemaRegistrySchemaFetcher(configMap);
+ }
+ return null;
+ }
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistrySchemaFetcher.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistrySchemaFetcher.java
new file mode 100644
index 000000000..2dacfe38c
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistrySchemaFetcher.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.annotation.Internal;
+
+import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants;
+import org.apache.avro.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.glue.GlueClient;
+import software.amazon.awssdk.services.glue.GlueClientBuilder;
+import software.amazon.awssdk.services.glue.model.GetSchemaVersionRequest;
+import software.amazon.awssdk.services.glue.model.GetSchemaVersionResponse;
+import software.amazon.awssdk.services.glue.model.SchemaId;
+import software.amazon.awssdk.services.glue.model.SchemaVersionNumber;
+
+import java.net.URI;
+import java.util.Map;
+
+/**
+ * Fetches the latest Avro schema from AWS Glue Schema Registry using the Glue SDK.
+ *
+ * Used when {@code schema.fetchFromRegistry=true} to resolve the actual schema from GSR instead
+ * of relying on the auto-generated Flink schema (which defaults to namespace {@code
+ * org.apache.flink.avro.generated} and record name {@code record}).
+ */
+@Internal
+public class GlueSchemaRegistrySchemaFetcher implements AvroSchemaResolver.SchemaFetcher {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(GlueSchemaRegistrySchemaFetcher.class);
+
+ private final GlueClient glueClient;
+
+ public GlueSchemaRegistrySchemaFetcher(Map configMap) {
+ String region = (String) configMap.get(AWSSchemaRegistryConstants.AWS_REGION);
+ GlueClientBuilder builder = GlueClient.builder();
+ if (region != null) {
+ builder.region(Region.of(region));
+ }
+ Object endpoint = configMap.get(AWSSchemaRegistryConstants.AWS_ENDPOINT);
+ if (endpoint != null) {
+ builder.endpointOverride(URI.create(endpoint.toString()));
+ }
+ this.glueClient = builder.build();
+ LOG.debug("GlueSchemaRegistrySchemaFetcher initialized for region: {}", region);
+ }
+
+ /** Package-private constructor for testing with a pre-built GlueClient. */
+ GlueSchemaRegistrySchemaFetcher(GlueClient glueClient) {
+ this.glueClient = glueClient;
+ }
+
+ @Override
+ public Schema fetchSchema(String registryName, String schemaName) throws Exception {
+ LOG.debug(
+ "Fetching schema from GSR - registry: '{}', schema: '{}'",
+ registryName,
+ schemaName);
+
+ GetSchemaVersionRequest request =
+ GetSchemaVersionRequest.builder()
+ .schemaId(
+ SchemaId.builder()
+ .registryName(registryName)
+ .schemaName(schemaName)
+ .build())
+ .schemaVersionNumber(
+ SchemaVersionNumber.builder().latestVersion(true).build())
+ .build();
+
+ GetSchemaVersionResponse response = glueClient.getSchemaVersion(request);
+ String schemaDefinition = response.schemaDefinition();
+
+ if (schemaDefinition == null || schemaDefinition.isEmpty()) {
+ LOG.warn(
+ "Schema definition is null or empty for registry: '{}', schema: '{}'",
+ registryName,
+ schemaName);
+ return null;
+ }
+
+ Schema schema = new Schema.Parser().parse(schemaDefinition);
+ LOG.debug(
+ "Fetched schema - namespace: '{}', name: '{}', fields: {}",
+ schema.getNamespace(),
+ schema.getName(),
+ schema.getFields().size());
+
+ return schema;
+ }
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/main/resources/META-INF/services/org.apache.flink.table.factories.Factory b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/resources/META-INF/services/org.apache.flink.table.factories.Factory
new file mode 100644
index 000000000..2b14a1906
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/main/resources/META-INF/services/org.apache.flink.table.factories.Factory
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+org.apache.flink.formats.avro.glue.schema.registry.GlueSchemaRegistryAvroFormatFactory
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroRoundTripIntegrationTest.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroRoundTripIntegrationTest.java
new file mode 100644
index 000000000..9257f6118
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroRoundTripIntegrationTest.java
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.formats.avro.AvroRowDataDeserializationSchema;
+import org.apache.flink.formats.avro.AvroRowDataSerializationSchema;
+import org.apache.flink.formats.avro.AvroToRowDataConverters;
+import org.apache.flink.formats.avro.RowDataToAvroConverters;
+import org.apache.flink.formats.avro.SchemaCoder;
+import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.runtime.typeutils.InternalTypeInfo;
+import org.apache.flink.table.types.logical.IntType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.table.types.logical.VarCharType;
+
+import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants;
+import org.apache.avro.Schema;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for Avro round-trip serialization/deserialization with mock GSR facades.
+ *
+ * Validates Requirements 8.1, 8.3.
+ */
+class AvroRoundTripIntegrationTest {
+
+ private MockGlueSchemaRegistryFacades mockFacades;
+ private Map configs;
+
+ @BeforeEach
+ void setUp() {
+ mockFacades = new MockGlueSchemaRegistryFacades();
+ configs = new HashMap<>();
+ configs.put(AWSSchemaRegistryConstants.AWS_REGION, "us-west-2");
+ configs.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true);
+ configs.put(AWSSchemaRegistryConstants.SCHEMA_NAME, "test-schema");
+ }
+
+ /**
+ * Tests basic Avro round-trip: RowData → serialize → deserialize → RowData. Requirement 8.1.
+ */
+ @Test
+ void testBasicAvroRoundTrip() throws Exception {
+ RowType rowType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new RowType.RowField(
+ "name", new VarCharType(VarCharType.MAX_LENGTH)),
+ new RowType.RowField("age", new IntType())));
+
+ Schema avroSchema = AvroSchemaConverter.convertToSchema(rowType);
+
+ // Build mock SchemaCoder using mock facades
+ GlueSchemaRegistryOutputStreamSerializer mockSerializer =
+ mockFacades.createMockOutputStreamSerializer("test-topic", configs);
+ GlueSchemaRegistryInputStreamDeserializer mockDeserializer =
+ mockFacades.createMockInputStreamDeserializer();
+
+ SchemaCoder serCoder = new GlueSchemaRegistryAvroSchemaCoder(mockSerializer);
+ SchemaCoder deserCoder = new GlueSchemaRegistryAvroSchemaCoder(mockDeserializer);
+
+ // Create ser/deser schemas
+ GlueSchemaRegistryAvroSerializationSchema
+ gsrAvroSer =
+ new GlueSchemaRegistryAvroSerializationSchema<>(
+ org.apache.avro.generic.GenericRecord.class, avroSchema, serCoder);
+
+ AvroRowDataSerializationSchema serSchema =
+ new AvroRowDataSerializationSchema(
+ rowType, gsrAvroSer, RowDataToAvroConverters.createConverter(rowType));
+
+ AvroRowDataDeserializationSchema deserSchema =
+ new AvroRowDataDeserializationSchema(
+ createDeserSchemaWithMockCoder(avroSchema, deserCoder),
+ AvroToRowDataConverters.createRowConverter(rowType),
+ InternalTypeInfo.of(rowType));
+
+ // Open schemas
+ serSchema.open(null);
+ deserSchema.open(null);
+
+ // Create test RowData
+ GenericRowData original = new GenericRowData(2);
+ original.setField(0, StringData.fromString("Alice"));
+ original.setField(1, 30);
+
+ // Serialize
+ byte[] serialized = serSchema.serialize(original);
+ assertThat(serialized).isNotNull();
+ assertThat(serialized.length).isGreaterThan(MockGlueSchemaRegistryFacades.GSR_HEADER_SIZE);
+
+ // Deserialize
+ RowData deserialized = deserSchema.deserialize(serialized);
+ assertThat(deserialized).isNotNull();
+ assertThat(deserialized.getString(0).toString()).isEqualTo("Alice");
+ assertThat(deserialized.getInt(1)).isEqualTo(30);
+ }
+
+ /**
+ * Tests namespace bug scenario: serialize with avro.namespace override. Pre-register schema
+ * with custom namespace, then serialize with patched schema. Requirement 8.3.
+ */
+ @Test
+ void testNamespaceBugScenario() throws Exception {
+ RowType rowType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new RowType.RowField(
+ "name", new VarCharType(VarCharType.MAX_LENGTH)),
+ new RowType.RowField("age", new IntType())));
+
+ // Auto-generated schema has namespace "org.apache.flink.avro.generated"
+ Schema autoGenerated = AvroSchemaConverter.convertToSchema(rowType);
+ assertThat(autoGenerated.getNamespace()).isEqualTo("org.apache.flink.avro.generated");
+
+ // Patch schema with custom namespace (simulating avro.namespace option)
+ String customNamespace = "com.example.myapp";
+ Schema patchedSchema = AvroSchemaPatcher.patchSchema(autoGenerated, customNamespace, null);
+ assertThat(patchedSchema.getNamespace()).isEqualTo(customNamespace);
+ assertThat(patchedSchema.getFields()).hasSameSizeAs(autoGenerated.getFields());
+
+ // Build mock SchemaCoder with patched schema
+ GlueSchemaRegistryOutputStreamSerializer mockSerializer =
+ mockFacades.createMockOutputStreamSerializer("test-topic", configs);
+ GlueSchemaRegistryInputStreamDeserializer mockDeserializer =
+ mockFacades.createMockInputStreamDeserializer();
+
+ SchemaCoder serCoder = new GlueSchemaRegistryAvroSchemaCoder(mockSerializer);
+ SchemaCoder deserCoder = new GlueSchemaRegistryAvroSchemaCoder(mockDeserializer);
+
+ // Create ser schema with patched schema
+ GlueSchemaRegistryAvroSerializationSchema
+ gsrAvroSer =
+ new GlueSchemaRegistryAvroSerializationSchema<>(
+ org.apache.avro.generic.GenericRecord.class,
+ patchedSchema,
+ serCoder);
+
+ AvroRowDataSerializationSchema serSchema =
+ new AvroRowDataSerializationSchema(
+ rowType, gsrAvroSer, RowDataToAvroConverters.createConverter(rowType));
+
+ // Create deser schema with patched schema
+ AvroRowDataDeserializationSchema deserSchema =
+ new AvroRowDataDeserializationSchema(
+ createDeserSchemaWithMockCoder(patchedSchema, deserCoder),
+ AvroToRowDataConverters.createRowConverter(rowType),
+ InternalTypeInfo.of(rowType));
+
+ serSchema.open(null);
+ deserSchema.open(null);
+
+ // Create test RowData
+ GenericRowData original = new GenericRowData(2);
+ original.setField(0, StringData.fromString("Bob"));
+ original.setField(1, 25);
+
+ // Serialize with patched schema
+ byte[] serialized = serSchema.serialize(original);
+ assertThat(serialized).isNotNull();
+
+ // Deserialize — should succeed despite different namespace
+ RowData deserialized = deserSchema.deserialize(serialized);
+ assertThat(deserialized).isNotNull();
+ assertThat(deserialized.getString(0).toString()).isEqualTo("Bob");
+ assertThat(deserialized.getInt(1)).isEqualTo(25);
+ }
+
+ /**
+ * Creates a GlueSchemaRegistryAvroDeserializationSchema with a mock SchemaCoder injected via
+ * reflection (the schemaCoder field is private in the parent class).
+ */
+ private static GlueSchemaRegistryAvroDeserializationSchema<
+ org.apache.avro.generic.GenericRecord>
+ createDeserSchemaWithMockCoder(Schema schema, SchemaCoder mockCoder) {
+ // Create a real deser schema (configs won't be used since we override schemaCoder)
+ Map dummyConfigs = new HashMap<>();
+ dummyConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, "us-west-2");
+ GlueSchemaRegistryAvroDeserializationSchema
+ deserSchema =
+ GlueSchemaRegistryAvroDeserializationSchema.forGeneric(
+ schema, dummyConfigs);
+
+ // Use reflection to inject the mock SchemaCoder
+ try {
+ Class> clazz = deserSchema.getClass();
+ while (clazz != null) {
+ try {
+ Field field = clazz.getDeclaredField("schemaCoder");
+ field.setAccessible(true);
+ field.set(deserSchema, mockCoder);
+ return deserSchema;
+ } catch (NoSuchFieldException e) {
+ clazz = clazz.getSuperclass();
+ }
+ }
+ throw new RuntimeException("Could not find schemaCoder field");
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException("Failed to inject mock SchemaCoder", e);
+ }
+ }
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroRoundTripPropertyTest.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroRoundTripPropertyTest.java
new file mode 100644
index 000000000..1e9355826
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroRoundTripPropertyTest.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.formats.avro.AvroRowDataDeserializationSchema;
+import org.apache.flink.formats.avro.AvroRowDataSerializationSchema;
+import org.apache.flink.formats.avro.AvroToRowDataConverters;
+import org.apache.flink.formats.avro.RowDataToAvroConverters;
+import org.apache.flink.formats.avro.SchemaCoder;
+import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.runtime.typeutils.InternalTypeInfo;
+import org.apache.flink.table.types.logical.BooleanType;
+import org.apache.flink.table.types.logical.DoubleType;
+import org.apache.flink.table.types.logical.IntType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.table.types.logical.VarCharType;
+
+import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants;
+import net.jqwik.api.Arbitraries;
+import net.jqwik.api.Arbitrary;
+import net.jqwik.api.ForAll;
+import net.jqwik.api.Property;
+import net.jqwik.api.Provide;
+import net.jqwik.api.Tag;
+import org.apache.avro.Schema;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Property-based tests for Avro serialization round-trip with mock GSR facades.
+ *
+ * Property 3: Avro serialization round-trip
+ *
+ *
Validates: Requirements 1.4, 1.5, 8.1
+ */
+@Tag("Feature: gsr-flink-sql-formats, Property 3: Avro serialization round-trip")
+class AvroRoundTripPropertyTest {
+
+ /**
+ * For any valid RowData matching a given RowType, serializing via the Avro encoding format
+ * (with mock GSR facades) and then deserializing should produce equivalent RowData.
+ */
+ @Property(tries = 100)
+ void avroRoundTripPreservesData(@ForAll("rowDataWithType") RowDataWithType input)
+ throws Exception {
+ RowType rowType = input.rowType;
+ RowData original = input.rowData;
+
+ Schema avroSchema = AvroSchemaConverter.convertToSchema(rowType);
+
+ // Create mock facades
+ MockGlueSchemaRegistryFacades mockFacades = new MockGlueSchemaRegistryFacades();
+ Map configs = new HashMap<>();
+ configs.put(AWSSchemaRegistryConstants.AWS_REGION, "us-west-2");
+ configs.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true);
+ configs.put(AWSSchemaRegistryConstants.SCHEMA_NAME, "test-schema");
+
+ GlueSchemaRegistryOutputStreamSerializer mockSerializer =
+ mockFacades.createMockOutputStreamSerializer("test-topic", configs);
+ GlueSchemaRegistryInputStreamDeserializer mockDeserializer =
+ mockFacades.createMockInputStreamDeserializer();
+
+ SchemaCoder serCoder = new GlueSchemaRegistryAvroSchemaCoder(mockSerializer);
+ SchemaCoder deserCoder = new GlueSchemaRegistryAvroSchemaCoder(mockDeserializer);
+
+ // Create serialization schema
+ GlueSchemaRegistryAvroSerializationSchema
+ gsrAvroSer =
+ new GlueSchemaRegistryAvroSerializationSchema<>(
+ org.apache.avro.generic.GenericRecord.class, avroSchema, serCoder);
+
+ AvroRowDataSerializationSchema serSchema =
+ new AvroRowDataSerializationSchema(
+ rowType, gsrAvroSer, RowDataToAvroConverters.createConverter(rowType));
+
+ // Create deserialization schema with mock coder via reflection
+ GlueSchemaRegistryAvroDeserializationSchema
+ gsrAvroDe =
+ GlueSchemaRegistryAvroDeserializationSchema.forGeneric(avroSchema, configs);
+ injectSchemaCoder(gsrAvroDe, deserCoder);
+
+ AvroRowDataDeserializationSchema deserSchema =
+ new AvroRowDataDeserializationSchema(
+ gsrAvroDe,
+ AvroToRowDataConverters.createRowConverter(rowType),
+ InternalTypeInfo.of(rowType));
+
+ serSchema.open(null);
+ deserSchema.open(null);
+
+ // Serialize
+ byte[] serialized = serSchema.serialize(original);
+ assertThat(serialized).isNotNull();
+
+ // Deserialize
+ RowData deserialized = deserSchema.deserialize(serialized);
+ assertThat(deserialized).isNotNull();
+
+ // Verify equivalence field by field
+ assertRowDataEquals(original, deserialized, rowType);
+ }
+
+ /** Injects a mock SchemaCoder into a deser schema via reflection. */
+ private static void injectSchemaCoder(Object target, SchemaCoder coder) {
+ try {
+ Class> clazz = target.getClass();
+ while (clazz != null) {
+ try {
+ Field field = clazz.getDeclaredField("schemaCoder");
+ field.setAccessible(true);
+ field.set(target, coder);
+ return;
+ } catch (NoSuchFieldException e) {
+ clazz = clazz.getSuperclass();
+ }
+ }
+ throw new RuntimeException("Could not find schemaCoder field");
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException("Failed to inject mock SchemaCoder", e);
+ }
+ }
+
+ /** Compares two RowData instances field by field based on the RowType. */
+ private void assertRowDataEquals(RowData expected, RowData actual, RowType rowType) {
+ assertThat(actual.getArity()).isEqualTo(expected.getArity());
+ for (int i = 0; i < rowType.getFieldCount(); i++) {
+ LogicalType fieldType = rowType.getTypeAt(i);
+ // Avro treats null strings as null, null ints/bools/doubles as null
+ if (expected.isNullAt(i)) {
+ assertThat(actual.isNullAt(i)).isTrue();
+ continue;
+ }
+ if (fieldType instanceof VarCharType) {
+ assertThat(actual.getString(i).toString())
+ .isEqualTo(expected.getString(i).toString());
+ } else if (fieldType instanceof IntType) {
+ assertThat(actual.getInt(i)).isEqualTo(expected.getInt(i));
+ } else if (fieldType instanceof BooleanType) {
+ assertThat(actual.getBoolean(i)).isEqualTo(expected.getBoolean(i));
+ } else if (fieldType instanceof DoubleType) {
+ assertThat(actual.getDouble(i)).isEqualTo(expected.getDouble(i));
+ }
+ }
+ }
+
+ // --- Generators ---
+
+ @Provide
+ Arbitrary rowDataWithType() {
+ // Fixed schema with STRING, INT, BOOLEAN, DOUBLE fields
+ RowType rowType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new RowType.RowField(
+ "name", new VarCharType(VarCharType.MAX_LENGTH)),
+ new RowType.RowField("age", new IntType()),
+ new RowType.RowField("active", new BooleanType()),
+ new RowType.RowField("score", new DoubleType())));
+
+ return Arbitraries.of(rowType)
+ .flatMap(rt -> generateRowData(rt).map(rd -> new RowDataWithType(rt, rd)));
+ }
+
+ private Arbitrary generateRowData(RowType rowType) {
+ Arbitrary strings = Arbitraries.strings().alpha().ofMinLength(0).ofMaxLength(50);
+ Arbitrary ints = Arbitraries.integers().between(-10000, 10000);
+ Arbitrary bools = Arbitraries.of(true, false);
+ Arbitrary doubles = Arbitraries.doubles().between(-1e6, 1e6).ofScale(4);
+
+ return strings.flatMap(
+ name ->
+ ints.flatMap(
+ age ->
+ bools.flatMap(
+ active ->
+ doubles.map(
+ score -> {
+ GenericRowData row =
+ new GenericRowData(4);
+ row.setField(
+ 0,
+ StringData.fromString(
+ name));
+ row.setField(1, age);
+ row.setField(2, active);
+ row.setField(3, score);
+ return (RowData) row;
+ }))));
+ }
+
+ /** Holder for a RowData and its corresponding RowType. */
+ static class RowDataWithType {
+ final RowType rowType;
+ final RowData rowData;
+
+ RowDataWithType(RowType rowType, RowData rowData) {
+ this.rowType = rowType;
+ this.rowData = rowData;
+ }
+ }
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaPatcherPropertyTest.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaPatcherPropertyTest.java
new file mode 100644
index 000000000..32e89a773
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaPatcherPropertyTest.java
@@ -0,0 +1,414 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter;
+import org.apache.flink.table.types.logical.ArrayType;
+import org.apache.flink.table.types.logical.BigIntType;
+import org.apache.flink.table.types.logical.BooleanType;
+import org.apache.flink.table.types.logical.DoubleType;
+import org.apache.flink.table.types.logical.FloatType;
+import org.apache.flink.table.types.logical.IntType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.MapType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.table.types.logical.VarCharType;
+
+import net.jqwik.api.Arbitraries;
+import net.jqwik.api.Arbitrary;
+import net.jqwik.api.ForAll;
+import net.jqwik.api.Property;
+import net.jqwik.api.Provide;
+import net.jqwik.api.Tag;
+import net.jqwik.api.Tuple;
+import org.apache.avro.Schema;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Property-based tests for {@link AvroSchemaPatcher}.
+ *
+ * Property 2: Avro schema patching preserves fields with overridden namespace and name
+ *
+ *
Validates: Requirements 2.3, 2.4, 2.9
+ */
+@Tag(
+ "Feature: gsr-flink-sql-formats, Property 2: Avro schema patching preserves fields with"
+ + " overridden namespace and name")
+class AvroSchemaPatcherPropertyTest {
+
+ /**
+ * For any valid Flink RowType and for any non-empty namespace and record name strings, patching
+ * the auto-generated Avro schema should produce a schema where the namespace and record name
+ * match the provided values, and all fields are preserved (with nested records also patched).
+ */
+ @Property(tries = 100)
+ void patchSchemaPreservesFieldsWithOverriddenNamespaceAndName(
+ @ForAll("rowTypes") RowType rowType,
+ @ForAll("namespaces") String namespace,
+ @ForAll("recordNames") String recordName) {
+
+ Schema original = AvroSchemaConverter.convertToSchema(rowType);
+ Schema patched = AvroSchemaPatcher.patchSchema(original, namespace, recordName);
+
+ assertThat(patched.getNamespace()).isEqualTo(namespace);
+ assertThat(patched.getName()).isEqualTo(recordName);
+ assertThat(patched.getFields()).hasSameSizeAs(original.getFields());
+
+ for (int i = 0; i < original.getFields().size(); i++) {
+ Schema.Field originalField = original.getFields().get(i);
+ Schema.Field patchedField = patched.getFields().get(i);
+ assertThat(patchedField.name()).isEqualTo(originalField.name());
+ // For nested records, the schema will be patched too, so we verify structure
+ assertSchemaStructureMatches(originalField.schema(), patchedField.schema(), namespace);
+ }
+ }
+
+ /**
+ * Verifies that the patched schema has the same structure as the original, with nested records
+ * having the new namespace.
+ */
+ private void assertSchemaStructureMatches(
+ Schema original, Schema patched, String expectedNamespace) {
+ assertThat(patched.getType()).isEqualTo(original.getType());
+
+ switch (original.getType()) {
+ case RECORD:
+ assertThat(patched.getNamespace()).isEqualTo(expectedNamespace);
+ assertThat(patched.getFields()).hasSameSizeAs(original.getFields());
+ for (int i = 0; i < original.getFields().size(); i++) {
+ assertSchemaStructureMatches(
+ original.getFields().get(i).schema(),
+ patched.getFields().get(i).schema(),
+ expectedNamespace);
+ }
+ break;
+ case ARRAY:
+ assertSchemaStructureMatches(
+ original.getElementType(), patched.getElementType(), expectedNamespace);
+ break;
+ case MAP:
+ assertSchemaStructureMatches(
+ original.getValueType(), patched.getValueType(), expectedNamespace);
+ break;
+ case UNION:
+ assertThat(patched.getTypes()).hasSameSizeAs(original.getTypes());
+ for (int i = 0; i < original.getTypes().size(); i++) {
+ assertSchemaStructureMatches(
+ original.getTypes().get(i),
+ patched.getTypes().get(i),
+ expectedNamespace);
+ }
+ break;
+ default:
+ // Primitive types should be equal
+ assertThat(patched).isEqualTo(original);
+ }
+ }
+
+ /**
+ * When only namespace is provided (recordName is null), the record name should remain
+ * unchanged.
+ */
+ @Property(tries = 100)
+ void patchSchemaWithOnlyNamespacePreservesRecordName(
+ @ForAll("rowTypes") RowType rowType, @ForAll("namespaces") String namespace) {
+
+ Schema original = AvroSchemaConverter.convertToSchema(rowType);
+ Schema patched = AvroSchemaPatcher.patchSchema(original, namespace, null);
+
+ assertThat(patched.getNamespace()).isEqualTo(namespace);
+ assertThat(patched.getName()).isEqualTo(original.getName());
+ assertThat(patched.getFields()).hasSameSizeAs(original.getFields());
+ }
+
+ /**
+ * When only recordName is provided (namespace is null), the namespace should remain unchanged.
+ */
+ @Property(tries = 100)
+ void patchSchemaWithOnlyRecordNamePreservesNamespace(
+ @ForAll("rowTypes") RowType rowType, @ForAll("recordNames") String recordName) {
+
+ Schema original = AvroSchemaConverter.convertToSchema(rowType);
+ Schema patched = AvroSchemaPatcher.patchSchema(original, null, recordName);
+
+ assertThat(patched.getNamespace()).isEqualTo(original.getNamespace());
+ assertThat(patched.getName()).isEqualTo(recordName);
+ assertThat(patched.getFields()).hasSameSizeAs(original.getFields());
+ }
+
+ /**
+ * When both namespace and recordName are null, the original schema should be returned
+ * unchanged.
+ */
+ @Property(tries = 100)
+ void patchSchemaWithNullOverridesReturnsOriginal(@ForAll("rowTypes") RowType rowType) {
+
+ Schema original = AvroSchemaConverter.convertToSchema(rowType);
+ Schema patched = AvroSchemaPatcher.patchSchema(original, null, null);
+
+ assertThat(patched).isSameAs(original);
+ }
+
+ @Provide
+ Arbitrary rowTypes() {
+ return rowTypesWithDepth(0);
+ }
+
+ /** Generates RowTypes with nested complex types up to a maximum depth. */
+ private Arbitrary rowTypesWithDepth(int depth) {
+ Arbitrary fieldCount = Arbitraries.integers().between(1, 5);
+ return fieldCount.flatMap(
+ count -> {
+ Arbitrary> types =
+ logicalTypesWithDepth(depth).list().ofSize(count);
+ return types.map(
+ typeList -> {
+ List fields =
+ IntStream.range(0, typeList.size())
+ .mapToObj(
+ i ->
+ new RowType.RowField(
+ "f" + i, typeList.get(i)))
+ .collect(Collectors.toList());
+ return new RowType(false, fields);
+ });
+ });
+ }
+
+ /**
+ * Generates LogicalTypes including primitives and complex types (ARRAY, MAP, ROW) with depth
+ * control.
+ */
+ private Arbitrary logicalTypesWithDepth(int depth) {
+ // Primitive types - always available
+ Arbitrary primitives =
+ Arbitraries.of(
+ (LogicalType) new VarCharType(VarCharType.MAX_LENGTH),
+ new IntType(),
+ new BigIntType(),
+ new BooleanType(),
+ new FloatType(),
+ new DoubleType());
+
+ // At max depth, only return primitives
+ if (depth >= 2) {
+ return primitives;
+ }
+
+ // Complex types with nested structures
+ Arbitrary arrayType = logicalTypesWithDepth(depth + 1).map(ArrayType::new);
+
+ Arbitrary mapType =
+ logicalTypesWithDepth(depth + 1)
+ .map(
+ valueType ->
+ new MapType(
+ new VarCharType(VarCharType.MAX_LENGTH),
+ valueType));
+
+ Arbitrary nestedRowType =
+ rowTypesWithDepth(depth + 1).map(rt -> (LogicalType) rt);
+
+ // Mix primitives and complex types with higher weight on primitives
+ return Arbitraries.frequencyOf(
+ Tuple.of(6, primitives),
+ Tuple.of(1, arrayType),
+ Tuple.of(1, mapType),
+ Tuple.of(2, nestedRowType));
+ }
+
+ @Provide
+ Arbitrary namespaces() {
+ return Arbitraries.strings()
+ .withCharRange('a', 'z')
+ .ofMinLength(1)
+ .ofMaxLength(20)
+ .flatMap(
+ first ->
+ Arbitraries.strings()
+ .withCharRange('a', 'z')
+ .ofMinLength(1)
+ .ofMaxLength(20)
+ .map(second -> first + "." + second));
+ }
+
+ @Provide
+ Arbitrary recordNames() {
+ // Avro record names must start with a letter and contain only alphanumeric + underscore
+ return Arbitraries.strings()
+ .withCharRange('A', 'Z')
+ .ofLength(1)
+ .flatMap(
+ first ->
+ Arbitraries.strings()
+ .withCharRange('a', 'z')
+ .ofMinLength(1)
+ .ofMaxLength(15)
+ .map(rest -> first + rest));
+ }
+
+ /** Test that nested record types are also patched with the same namespace. */
+ @Test
+ void patchSchemaRecursivelyPatchesNestedRecords() {
+ // Create a RowType with nested ROW (record within record)
+ RowType nestedRowType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new RowType.RowField(
+ "name", new VarCharType(VarCharType.MAX_LENGTH)),
+ new RowType.RowField("age", new IntType())));
+
+ RowType outerRowType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new RowType.RowField("id", new VarCharType(VarCharType.MAX_LENGTH)),
+ new RowType.RowField("customer", nestedRowType)));
+
+ Schema original = AvroSchemaConverter.convertToSchema(outerRowType);
+ Schema patched =
+ AvroSchemaPatcher.patchSchema(original, "com.example.orders", "OrderEvent");
+
+ // Verify root record is patched
+ assertThat(patched.getNamespace()).isEqualTo("com.example.orders");
+ assertThat(patched.getName()).isEqualTo("OrderEvent");
+
+ // Verify nested record is also patched with the same namespace
+ Schema customerField = patched.getField("customer").schema();
+ // Handle union type (nullable)
+ if (customerField.getType() == Schema.Type.UNION) {
+ customerField =
+ customerField.getTypes().stream()
+ .filter(s -> s.getType() == Schema.Type.RECORD)
+ .findFirst()
+ .orElseThrow();
+ }
+ assertThat(customerField.getNamespace()).isEqualTo("com.example.orders");
+ }
+
+ /** Test that ARRAY of records has nested records patched. */
+ @Test
+ void patchSchemaRecursivelyPatchesArrayOfRecords() {
+ // Create a RowType with ARRAY>
+ RowType itemRowType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new RowType.RowField(
+ "product_name", new VarCharType(VarCharType.MAX_LENGTH)),
+ new RowType.RowField("quantity", new IntType())));
+
+ RowType outerRowType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new RowType.RowField(
+ "order_id", new VarCharType(VarCharType.MAX_LENGTH)),
+ new RowType.RowField("items", new ArrayType(itemRowType))));
+
+ Schema original = AvroSchemaConverter.convertToSchema(outerRowType);
+ Schema patched =
+ AvroSchemaPatcher.patchSchema(original, "com.example.orders", "OrderEvent");
+
+ // Verify root record is patched
+ assertThat(patched.getNamespace()).isEqualTo("com.example.orders");
+ assertThat(patched.getName()).isEqualTo("OrderEvent");
+
+ // Verify array element record is also patched
+ Schema itemsField = patched.getField("items").schema();
+ // Handle union type (nullable)
+ if (itemsField.getType() == Schema.Type.UNION) {
+ itemsField =
+ itemsField.getTypes().stream()
+ .filter(s -> s.getType() == Schema.Type.ARRAY)
+ .findFirst()
+ .orElseThrow();
+ }
+ Schema elementSchema = itemsField.getElementType();
+ // Handle union type for element
+ if (elementSchema.getType() == Schema.Type.UNION) {
+ elementSchema =
+ elementSchema.getTypes().stream()
+ .filter(s -> s.getType() == Schema.Type.RECORD)
+ .findFirst()
+ .orElseThrow();
+ }
+ assertThat(elementSchema.getNamespace()).isEqualTo("com.example.orders");
+ }
+
+ /** Test that MAP values with record types are patched. */
+ @Test
+ void patchSchemaRecursivelyPatchesMapValues() {
+ // Create a RowType with MAP>
+ RowType valueRowType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new RowType.RowField(
+ "key", new VarCharType(VarCharType.MAX_LENGTH)),
+ new RowType.RowField("value", new IntType())));
+
+ RowType outerRowType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new RowType.RowField("id", new VarCharType(VarCharType.MAX_LENGTH)),
+ new RowType.RowField(
+ "metadata",
+ new MapType(
+ new VarCharType(VarCharType.MAX_LENGTH),
+ valueRowType))));
+
+ Schema original = AvroSchemaConverter.convertToSchema(outerRowType);
+ Schema patched = AvroSchemaPatcher.patchSchema(original, "com.example.data", "DataRecord");
+
+ // Verify root record is patched
+ assertThat(patched.getNamespace()).isEqualTo("com.example.data");
+ assertThat(patched.getName()).isEqualTo("DataRecord");
+
+ // Verify map value record is also patched
+ Schema metadataField = patched.getField("metadata").schema();
+ // Handle union type (nullable)
+ if (metadataField.getType() == Schema.Type.UNION) {
+ metadataField =
+ metadataField.getTypes().stream()
+ .filter(s -> s.getType() == Schema.Type.MAP)
+ .findFirst()
+ .orElseThrow();
+ }
+ Schema valueSchema = metadataField.getValueType();
+ // Handle union type for value
+ if (valueSchema.getType() == Schema.Type.UNION) {
+ valueSchema =
+ valueSchema.getTypes().stream()
+ .filter(s -> s.getType() == Schema.Type.RECORD)
+ .findFirst()
+ .orElseThrow();
+ }
+ assertThat(valueSchema.getNamespace()).isEqualTo("com.example.data");
+ }
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaResolverTest.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaResolverTest.java
new file mode 100644
index 000000000..d69791ee8
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/AvroSchemaResolverTest.java
@@ -0,0 +1,258 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.types.logical.RowType;
+
+import org.apache.avro.Schema;
+import org.apache.avro.SchemaBuilder;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link AvroSchemaResolver}.
+ *
+ * Tests the schema resolution flow:
+ *
+ *
+ * - If fetchFromRegistry is true and fetcher succeeds, use fetched schema
+ *
- If fetchFromRegistry is true but fetcher fails, fall back to patched/auto-generated schema
+ *
- If namespace/record-name overrides are provided, patch the schema
+ *
- Otherwise, use the auto-generated schema unchanged
+ *
+ */
+class AvroSchemaResolverTest {
+
+ private static final RowType TEST_ROW_TYPE =
+ (RowType)
+ DataTypes.ROW(
+ DataTypes.FIELD("id", DataTypes.STRING()),
+ DataTypes.FIELD("value", DataTypes.INT()))
+ .getLogicalType();
+
+ // Note: AvroSchemaConverter.convertToSchema returns a UNION ["null", record] for nullable rows
+ private static final Schema AUTO_GENERATED_SCHEMA =
+ AvroSchemaConverter.convertToSchema(TEST_ROW_TYPE);
+
+ private static final String REGISTRY_NAME = "test-registry";
+ private static final String SCHEMA_NAME = "test-schema";
+
+ /**
+ * Extracts the RECORD schema from a potentially UNION schema. AvroSchemaConverter wraps records
+ * in ["null", record] unions.
+ */
+ private static Schema extractRecordSchema(Schema schema) {
+ if (schema.getType() == Schema.Type.UNION) {
+ return schema.getTypes().stream()
+ .filter(s -> s.getType() == Schema.Type.RECORD)
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("No RECORD in UNION"));
+ }
+ return schema;
+ }
+
+ @Test
+ void testResolveSchemaWithFetchFromRegistrySuccess() {
+ // Create a mock fetched schema with custom namespace using SchemaBuilder
+ Schema fetchedSchema =
+ SchemaBuilder.record("FetchedRecord")
+ .namespace("com.example.fetched")
+ .fields()
+ .optionalString("id")
+ .optionalInt("value")
+ .endRecord();
+
+ MockSchemaFetcher fetcher = new MockSchemaFetcher(fetchedSchema);
+
+ Configuration config = new Configuration();
+ config.set(GlueFormatOptions.REGISTRY_NAME, REGISTRY_NAME);
+ config.set(GlueFormatOptions.SCHEMA_NAME, SCHEMA_NAME);
+ config.set(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY, true);
+
+ Schema resolved = AvroSchemaResolver.resolveSchema(AUTO_GENERATED_SCHEMA, config, fetcher);
+
+ assertThat(resolved.getNamespace()).isEqualTo("com.example.fetched");
+ assertThat(resolved.getName()).isEqualTo("FetchedRecord");
+ assertThat(fetcher.fetchCalled).isTrue();
+ assertThat(fetcher.lastRegistryName).isEqualTo(REGISTRY_NAME);
+ assertThat(fetcher.lastSchemaName).isEqualTo(SCHEMA_NAME);
+ }
+
+ @Test
+ void testResolveSchemaWithFetchFromRegistryFailure() {
+ // Fetcher that throws an exception
+ MockSchemaFetcher fetcher = new MockSchemaFetcher(new RuntimeException("GSR unavailable"));
+
+ Configuration config = new Configuration();
+ config.set(GlueFormatOptions.REGISTRY_NAME, REGISTRY_NAME);
+ config.set(GlueFormatOptions.SCHEMA_NAME, SCHEMA_NAME);
+ config.set(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY, true);
+ config.set(AvroGlueFormatOptions.AVRO_NAMESPACE, "com.example.fallback");
+
+ Schema resolved = AvroSchemaResolver.resolveSchema(AUTO_GENERATED_SCHEMA, config, fetcher);
+
+ // Should fall back to patched schema (extract RECORD from UNION)
+ Schema resolvedRecord = extractRecordSchema(resolved);
+ assertThat(resolvedRecord.getNamespace()).isEqualTo("com.example.fallback");
+ assertThat(fetcher.fetchCalled).isTrue();
+ }
+
+ @Test
+ void testResolveSchemaWithFetchFromRegistryReturnsNull() {
+ // Fetcher that returns null (schema not found)
+ MockSchemaFetcher fetcher = new MockSchemaFetcher((Schema) null);
+
+ Configuration config = new Configuration();
+ config.set(GlueFormatOptions.REGISTRY_NAME, REGISTRY_NAME);
+ config.set(GlueFormatOptions.SCHEMA_NAME, SCHEMA_NAME);
+ config.set(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY, true);
+ config.set(AvroGlueFormatOptions.AVRO_RECORD_NAME, "FallbackRecord");
+
+ Schema resolved = AvroSchemaResolver.resolveSchema(AUTO_GENERATED_SCHEMA, config, fetcher);
+
+ // Should fall back to patched schema (extract RECORD from UNION)
+ Schema resolvedRecord = extractRecordSchema(resolved);
+ assertThat(resolvedRecord.getName()).isEqualTo("FallbackRecord");
+ assertThat(fetcher.fetchCalled).isTrue();
+ }
+
+ @Test
+ void testResolveSchemaWithNamespaceOverrideOnly() {
+ Configuration config = new Configuration();
+ config.set(GlueFormatOptions.REGISTRY_NAME, REGISTRY_NAME);
+ config.set(GlueFormatOptions.SCHEMA_NAME, SCHEMA_NAME);
+ config.set(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY, false);
+ config.set(AvroGlueFormatOptions.AVRO_NAMESPACE, "com.example.custom");
+
+ Schema resolved = AvroSchemaResolver.resolveSchema(AUTO_GENERATED_SCHEMA, config, null);
+
+ // Extract RECORD from UNION for assertions
+ Schema resolvedRecord = extractRecordSchema(resolved);
+ Schema originalRecord = extractRecordSchema(AUTO_GENERATED_SCHEMA);
+ assertThat(resolvedRecord.getNamespace()).isEqualTo("com.example.custom");
+ assertThat(resolvedRecord.getName()).isEqualTo(originalRecord.getName());
+ }
+
+ @Test
+ void testResolveSchemaWithRecordNameOverrideOnly() {
+ Configuration config = new Configuration();
+ config.set(GlueFormatOptions.REGISTRY_NAME, REGISTRY_NAME);
+ config.set(GlueFormatOptions.SCHEMA_NAME, SCHEMA_NAME);
+ config.set(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY, false);
+ config.set(AvroGlueFormatOptions.AVRO_RECORD_NAME, "CustomRecord");
+
+ Schema resolved = AvroSchemaResolver.resolveSchema(AUTO_GENERATED_SCHEMA, config, null);
+
+ // Extract RECORD from UNION for assertions
+ Schema resolvedRecord = extractRecordSchema(resolved);
+ Schema originalRecord = extractRecordSchema(AUTO_GENERATED_SCHEMA);
+ assertThat(resolvedRecord.getNamespace()).isEqualTo(originalRecord.getNamespace());
+ assertThat(resolvedRecord.getName()).isEqualTo("CustomRecord");
+ }
+
+ @Test
+ void testResolveSchemaWithBothOverrides() {
+ Configuration config = new Configuration();
+ config.set(GlueFormatOptions.REGISTRY_NAME, REGISTRY_NAME);
+ config.set(GlueFormatOptions.SCHEMA_NAME, SCHEMA_NAME);
+ config.set(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY, false);
+ config.set(AvroGlueFormatOptions.AVRO_NAMESPACE, "com.example.custom");
+ config.set(AvroGlueFormatOptions.AVRO_RECORD_NAME, "CustomRecord");
+
+ Schema resolved = AvroSchemaResolver.resolveSchema(AUTO_GENERATED_SCHEMA, config, null);
+
+ // Extract RECORD from UNION for assertions
+ Schema resolvedRecord = extractRecordSchema(resolved);
+ assertThat(resolvedRecord.getNamespace()).isEqualTo("com.example.custom");
+ assertThat(resolvedRecord.getName()).isEqualTo("CustomRecord");
+ }
+
+ @Test
+ void testResolveSchemaWithNoOverrides() {
+ Configuration config = new Configuration();
+ config.set(GlueFormatOptions.REGISTRY_NAME, REGISTRY_NAME);
+ config.set(GlueFormatOptions.SCHEMA_NAME, SCHEMA_NAME);
+ config.set(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY, false);
+
+ Schema resolved = AvroSchemaResolver.resolveSchema(AUTO_GENERATED_SCHEMA, config, null);
+
+ // Should return the original schema unchanged
+ assertThat(resolved).isSameAs(AUTO_GENERATED_SCHEMA);
+ }
+
+ @Test
+ void testResolveSchemaWithFetchDisabledIgnoresFetcher() {
+ Schema fetchedSchema =
+ SchemaBuilder.record("FetchedRecord")
+ .namespace("com.example.fetched")
+ .fields()
+ .optionalString("id")
+ .optionalInt("value")
+ .endRecord();
+
+ MockSchemaFetcher fetcher = new MockSchemaFetcher(fetchedSchema);
+
+ Configuration config = new Configuration();
+ config.set(GlueFormatOptions.REGISTRY_NAME, REGISTRY_NAME);
+ config.set(GlueFormatOptions.SCHEMA_NAME, SCHEMA_NAME);
+ config.set(AvroGlueFormatOptions.SCHEMA_FETCH_FROM_REGISTRY, false);
+
+ Schema resolved = AvroSchemaResolver.resolveSchema(AUTO_GENERATED_SCHEMA, config, fetcher);
+
+ // Should NOT call fetcher when fetchFromRegistry is false
+ assertThat(fetcher.fetchCalled).isFalse();
+ assertThat(resolved).isSameAs(AUTO_GENERATED_SCHEMA);
+ }
+
+ /** Mock implementation of SchemaFetcher for testing. */
+ private static class MockSchemaFetcher implements AvroSchemaResolver.SchemaFetcher {
+ private final Schema schemaToReturn;
+ private final Exception exceptionToThrow;
+ boolean fetchCalled = false;
+ String lastRegistryName;
+ String lastSchemaName;
+
+ MockSchemaFetcher(@Nullable Schema schemaToReturn) {
+ this.schemaToReturn = schemaToReturn;
+ this.exceptionToThrow = null;
+ }
+
+ MockSchemaFetcher(Exception exceptionToThrow) {
+ this.schemaToReturn = null;
+ this.exceptionToThrow = exceptionToThrow;
+ }
+
+ @Override
+ public Schema fetchSchema(String registryName, String schemaName) throws Exception {
+ fetchCalled = true;
+ lastRegistryName = registryName;
+ lastSchemaName = schemaName;
+ if (exceptionToThrow != null) {
+ throw exceptionToThrow;
+ }
+ return schemaToReturn;
+ }
+ }
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/GlueFormatConfigBuilderPropertyTest.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/GlueFormatConfigBuilderPropertyTest.java
new file mode 100644
index 000000000..ecc2e7ac0
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/GlueFormatConfigBuilderPropertyTest.java
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.configuration.Configuration;
+
+import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants;
+import net.jqwik.api.Arbitraries;
+import net.jqwik.api.Arbitrary;
+import net.jqwik.api.Combinators;
+import net.jqwik.api.ForAll;
+import net.jqwik.api.Property;
+import net.jqwik.api.Provide;
+import net.jqwik.api.Tag;
+import software.amazon.awssdk.services.glue.model.Compatibility;
+
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Property-based tests for {@link GlueFormatConfigBuilder}.
+ *
+ * Property 6: Config map builder correctly maps all provided options
+ *
+ *
Validates: Requirements 5.1, 5.2
+ */
+@Tag(
+ "Feature: gsr-flink-sql-formats, Property 6: Config map builder correctly maps all provided"
+ + " options")
+class GlueFormatConfigBuilderPropertyTest {
+
+ /**
+ * For any valid combination of GSR config option values, building the config map via
+ * GlueFormatConfigBuilder.buildConfigMap() should produce a map where each provided option maps
+ * to the correct AWSSchemaRegistryConstants key with the same value, and absent options are not
+ * present in the map.
+ */
+ @Property(tries = 100)
+ void configMapBuilderCorrectlyMapsAllProvidedOptions(
+ @ForAll("gsrConfigCombinations") GsrConfigInput input) {
+
+ Configuration config = new Configuration();
+
+ input.region.ifPresent(v -> config.set(GlueFormatOptions.AWS_REGION, v));
+ input.endpoint.ifPresent(v -> config.set(GlueFormatOptions.AWS_ENDPOINT, v));
+ input.registryName.ifPresent(v -> config.set(GlueFormatOptions.REGISTRY_NAME, v));
+ input.schemaName.ifPresent(v -> config.set(GlueFormatOptions.SCHEMA_NAME, v));
+ input.cacheSize.ifPresent(v -> config.set(GlueFormatOptions.CACHE_SIZE, v));
+ input.cacheTtlMs.ifPresent(v -> config.set(GlueFormatOptions.CACHE_TTL_MS, v));
+ input.autoRegistration.ifPresent(
+ v -> config.set(GlueFormatOptions.SCHEMA_AUTO_REGISTRATION, v));
+ input.compatibility.ifPresent(v -> config.set(GlueFormatOptions.SCHEMA_COMPATIBILITY, v));
+ input.compression.ifPresent(v -> config.set(GlueFormatOptions.SCHEMA_COMPRESSION, v));
+
+ Map result = GlueFormatConfigBuilder.buildConfigMap(config);
+
+ // Verify present options map to correct keys with correct values
+ assertOptionalMapping(result, input.region, AWSSchemaRegistryConstants.AWS_REGION);
+ assertOptionalMapping(result, input.endpoint, AWSSchemaRegistryConstants.AWS_ENDPOINT);
+ assertOptionalMapping(result, input.registryName, AWSSchemaRegistryConstants.REGISTRY_NAME);
+ assertOptionalMapping(result, input.schemaName, AWSSchemaRegistryConstants.SCHEMA_NAME);
+ assertOptionalMapping(result, input.cacheSize, AWSSchemaRegistryConstants.CACHE_SIZE);
+ assertOptionalMapping(
+ result, input.cacheTtlMs, AWSSchemaRegistryConstants.CACHE_TIME_TO_LIVE_MILLIS);
+ assertOptionalMapping(
+ result,
+ input.autoRegistration,
+ AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING);
+ assertOptionalMapping(
+ result, input.compatibility, AWSSchemaRegistryConstants.COMPATIBILITY_SETTING);
+ // Compression must land as the enum NAME string: the GSR serde casts this
+ // config value to String at serializer init.
+ assertOptionalMapping(
+ result,
+ input.compression.map(Enum::name),
+ AWSSchemaRegistryConstants.COMPRESSION_TYPE);
+
+ // Verify map size equals number of provided options
+ long providedCount =
+ countPresent(
+ input.region,
+ input.endpoint,
+ input.registryName,
+ input.schemaName,
+ input.cacheSize,
+ input.cacheTtlMs,
+ input.autoRegistration,
+ input.compatibility,
+ input.compression);
+ assertThat(result).hasSize((int) providedCount);
+ }
+
+ @Provide
+ Arbitrary gsrConfigCombinations() {
+ Arbitrary> optRegion =
+ Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(20).optional();
+ Arbitrary> optEndpoint =
+ Arbitraries.strings()
+ .alpha()
+ .ofMinLength(1)
+ .ofMaxLength(30)
+ .map(s -> "https://" + s)
+ .optional();
+ Arbitrary> optRegistryName =
+ Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(30).optional();
+ Arbitrary> optSchemaName =
+ Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(30).optional();
+ Arbitrary> optCacheSize =
+ Arbitraries.integers().between(1, 10000).optional();
+ Arbitrary> optCacheTtlMs =
+ Arbitraries.longs().between(1000L, 172800000L).optional();
+ Arbitrary> optAutoReg = Arbitraries.of(true, false).optional();
+ Arbitrary> optCompat =
+ Arbitraries.of(Compatibility.knownValues().toArray(new Compatibility[0]))
+ .optional();
+ Arbitrary> optCompress =
+ Arbitraries.of(AWSSchemaRegistryConstants.COMPRESSION.values()).optional();
+
+ // jqwik Combinators.combine supports up to 8 params, so we nest via flatMap
+ return Combinators.combine(
+ optRegion,
+ optEndpoint,
+ optRegistryName,
+ optSchemaName,
+ optCacheSize,
+ optCacheTtlMs,
+ optAutoReg,
+ optCompat)
+ .flatAs(
+ (region, endpoint, registry, schema, cache, ttl, autoReg, compat) ->
+ optCompress.map(
+ compress ->
+ new GsrConfigInput(
+ region, endpoint, registry, schema, cache,
+ ttl, autoReg, compat, compress)));
+ }
+
+ private static void assertOptionalMapping(
+ Map result, Optional optionalValue, String expectedKey) {
+ if (optionalValue.isPresent()) {
+ assertThat(result).containsEntry(expectedKey, optionalValue.get());
+ } else {
+ assertThat(result).doesNotContainKey(expectedKey);
+ }
+ }
+
+ private static long countPresent(Optional>... optionals) {
+ long count = 0;
+ for (Optional> opt : optionals) {
+ if (opt.isPresent()) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ /** Value object holding an arbitrary combination of GSR config options. */
+ static class GsrConfigInput {
+ final Optional region;
+ final Optional endpoint;
+ final Optional registryName;
+ final Optional schemaName;
+ final Optional cacheSize;
+ final Optional cacheTtlMs;
+ final Optional autoRegistration;
+ final Optional compatibility;
+ final Optional compression;
+
+ GsrConfigInput(
+ Optional region,
+ Optional endpoint,
+ Optional registryName,
+ Optional schemaName,
+ Optional cacheSize,
+ Optional cacheTtlMs,
+ Optional autoRegistration,
+ Optional compatibility,
+ Optional compression) {
+ this.region = region;
+ this.endpoint = endpoint;
+ this.registryName = registryName;
+ this.schemaName = schemaName;
+ this.cacheSize = cacheSize;
+ this.cacheTtlMs = cacheTtlMs;
+ this.autoRegistration = autoRegistration;
+ this.compatibility = compatibility;
+ this.compression = compression;
+ }
+
+ @Override
+ public String toString() {
+ return "GsrConfigInput{"
+ + "region="
+ + region
+ + ", endpoint="
+ + endpoint
+ + ", registryName="
+ + registryName
+ + ", schemaName="
+ + schemaName
+ + ", cacheSize="
+ + cacheSize
+ + ", cacheTtlMs="
+ + cacheTtlMs
+ + ", autoRegistration="
+ + autoRegistration
+ + ", compatibility="
+ + compatibility
+ + ", compression="
+ + compression
+ + '}';
+ }
+ }
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistryAvroFormatFactoryPropertyTest.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistryAvroFormatFactoryPropertyTest.java
new file mode 100644
index 000000000..b5875fd40
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistryAvroFormatFactoryPropertyTest.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.factories.TestDynamicTableFactory;
+
+import net.jqwik.api.Arbitraries;
+import net.jqwik.api.Arbitrary;
+import net.jqwik.api.Combinators;
+import net.jqwik.api.ForAll;
+import net.jqwik.api.Property;
+import net.jqwik.api.Provide;
+import net.jqwik.api.Tag;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.apache.flink.table.factories.utils.FactoryMocks.createTableSource;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Property-based tests for required option validation in {@link
+ * GlueSchemaRegistryAvroFormatFactory}.
+ *
+ * Validates: Requirements 1.2, 3.2, 4.2, 5.3
+ */
+@Tag(
+ "Feature: gsr-flink-sql-formats, Property 1: Required option validation across all format"
+ + " factories")
+class GlueSchemaRegistryAvroFormatFactoryPropertyTest {
+
+ private static final ResolvedSchema SCHEMA =
+ ResolvedSchema.of(
+ Column.physical("a", DataTypes.STRING()),
+ Column.physical("b", DataTypes.INT()),
+ Column.physical("c", DataTypes.BOOLEAN()));
+
+ /**
+ * For any subset of required options that is missing at least one required option, creating a
+ * table source should throw a ValidationException.
+ *
+ *
Required options: aws.region, registry.name, schema.name
+ */
+ @Property(tries = 100)
+ void missingAnyRequiredOptionCausesValidationException(
+ @ForAll("incompleteRequiredOptionSets") RequiredOptionSubset subset) {
+
+ Map options = new HashMap<>();
+ options.put("connector", TestDynamicTableFactory.IDENTIFIER);
+ options.put("target", "MyTarget");
+ options.put("buffer-size", "1000");
+ options.put("format", GlueSchemaRegistryAvroFormatFactory.IDENTIFIER);
+
+ if (subset.includeRegion) {
+ options.put("avro-glue.aws.region", subset.regionValue);
+ }
+ if (subset.includeRegistryName) {
+ options.put("avro-glue.registry.name", subset.registryNameValue);
+ }
+ if (subset.includeSchemaName) {
+ options.put("avro-glue.schema.name", subset.schemaNameValue);
+ }
+
+ assertThatThrownBy(() -> createTableSource(SCHEMA, options))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Provide
+ Arbitrary incompleteRequiredOptionSets() {
+ Arbitrary bools = Arbitraries.of(true, false);
+ Arbitrary regionValues =
+ Arbitraries.of("us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1");
+ Arbitrary registryValues =
+ Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(20);
+ Arbitrary schemaValues =
+ Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(20);
+
+ return Combinators.combine(bools, bools, bools, regionValues, registryValues, schemaValues)
+ .as(RequiredOptionSubset::new)
+ // Filter to only keep subsets where at least one required option is missing
+ .filter(s -> !(s.includeRegion && s.includeRegistryName && s.includeSchemaName));
+ }
+
+ /** Value object representing a subset of required options. */
+ static class RequiredOptionSubset {
+ final boolean includeRegion;
+ final boolean includeRegistryName;
+ final boolean includeSchemaName;
+ final String regionValue;
+ final String registryNameValue;
+ final String schemaNameValue;
+
+ RequiredOptionSubset(
+ boolean includeRegion,
+ boolean includeRegistryName,
+ boolean includeSchemaName,
+ String regionValue,
+ String registryNameValue,
+ String schemaNameValue) {
+ this.includeRegion = includeRegion;
+ this.includeRegistryName = includeRegistryName;
+ this.includeSchemaName = includeSchemaName;
+ this.regionValue = regionValue;
+ this.registryNameValue = registryNameValue;
+ this.schemaNameValue = schemaNameValue;
+ }
+
+ @Override
+ public String toString() {
+ return "RequiredOptionSubset{"
+ + "region="
+ + (includeRegion ? regionValue : "")
+ + ", registryName="
+ + (includeRegistryName ? registryNameValue : "")
+ + ", schemaName="
+ + (includeSchemaName ? schemaNameValue : "")
+ + '}';
+ }
+ }
+}
diff --git a/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistryAvroFormatFactoryTest.java b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistryAvroFormatFactoryTest.java
new file mode 100644
index 000000000..f215d400d
--- /dev/null
+++ b/flink-formats-aws/flink-avro-glue-schema-registry/src/test/java/org/apache/flink/formats/avro/glue/schema/registry/GlueSchemaRegistryAvroFormatFactoryTest.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.formats.avro.glue.schema.registry;
+
+import org.apache.flink.api.common.serialization.DeserializationSchema;
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.formats.avro.AvroRowDataDeserializationSchema;
+import org.apache.flink.formats.avro.AvroRowDataSerializationSchema;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.connector.sink.DynamicTableSink;
+import org.apache.flink.table.connector.source.DynamicTableSource;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.factories.TestDynamicTableFactory;
+import org.apache.flink.table.runtime.connector.source.ScanRuntimeProviderContext;
+import org.apache.flink.table.types.logical.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import static org.apache.flink.table.factories.utils.FactoryMocks.createTableSink;
+import static org.apache.flink.table.factories.utils.FactoryMocks.createTableSource;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for the {@link GlueSchemaRegistryAvroFormatFactory}. */
+class GlueSchemaRegistryAvroFormatFactoryTest {
+
+ private static final ResolvedSchema SCHEMA =
+ ResolvedSchema.of(
+ Column.physical("a", DataTypes.STRING()),
+ Column.physical("b", DataTypes.INT()),
+ Column.physical("c", DataTypes.BOOLEAN()));
+
+ private static final RowType ROW_TYPE =
+ (RowType) SCHEMA.toPhysicalRowDataType().getLogicalType();
+
+ private static final String SCHEMA_NAME = "test-subject";
+ private static final String REGISTRY_NAME = "test-registry-name";
+ private static final String REGION = "us-west-2";
+
+ @Test
+ void testDeserializationSchema() {
+ final DynamicTableSource actualSource = createTableSource(SCHEMA, getDefaultOptions());
+ assertThat(actualSource).isInstanceOf(TestDynamicTableFactory.DynamicTableSourceMock.class);
+
+ TestDynamicTableFactory.DynamicTableSourceMock scanSourceMock =
+ (TestDynamicTableFactory.DynamicTableSourceMock) actualSource;
+
+ DeserializationSchema actualDeser =
+ scanSourceMock.valueFormat.createRuntimeDecoder(
+ ScanRuntimeProviderContext.INSTANCE, SCHEMA.toPhysicalRowDataType());
+
+ assertThat(actualDeser).isInstanceOf(AvroRowDataDeserializationSchema.class);
+ }
+
+ @Test
+ void testSerializationSchema() {
+ final DynamicTableSink actualSink = createTableSink(SCHEMA, getDefaultOptions());
+ assertThat(actualSink).isInstanceOf(TestDynamicTableFactory.DynamicTableSinkMock.class);
+
+ TestDynamicTableFactory.DynamicTableSinkMock sinkMock =
+ (TestDynamicTableFactory.DynamicTableSinkMock) actualSink;
+
+ SerializationSchema actualSer =
+ sinkMock.valueFormat.createRuntimeEncoder(null, SCHEMA.toPhysicalRowDataType());
+
+ assertThat(actualSer).isInstanceOf(AvroRowDataSerializationSchema.class);
+ }
+
+ @Test
+ void testMissingSchemaNameForSink() {
+ final Map options =
+ getModifiedOptions(opts -> opts.remove("avro-glue.schema.name"));
+
+ assertThatThrownBy(() -> createTableSink(SCHEMA, options))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void testMissingRegionForSource() {
+ final Map options =
+ getModifiedOptions(opts -> opts.remove("avro-glue.aws.region"));
+
+ assertThatThrownBy(() -> createTableSource(SCHEMA, options))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void testMissingRegistryNameForSource() {
+ final Map options =
+ getModifiedOptions(opts -> opts.remove("avro-glue.registry.name"));
+
+ assertThatThrownBy(() -> createTableSource(SCHEMA, options))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void testDeserializationSchemaWithNamespaceOverride() {
+ final String customNamespace = "com.example.custom";
+ final Map options =
+ getModifiedOptions(opts -> opts.put("avro-glue.avro.namespace", customNamespace));
+
+ final DynamicTableSource actualSource = createTableSource(SCHEMA, options);
+ assertThat(actualSource).isInstanceOf(TestDynamicTableFactory.DynamicTableSourceMock.class);
+
+ TestDynamicTableFactory.DynamicTableSourceMock scanSourceMock =
+ (TestDynamicTableFactory.DynamicTableSourceMock) actualSource;
+
+ DeserializationSchema actualDeser =
+ scanSourceMock.valueFormat.createRuntimeDecoder(
+ ScanRuntimeProviderContext.INSTANCE, SCHEMA.toPhysicalRowDataType());
+
+ // Verify the schema is created successfully with namespace override
+ assertThat(actualDeser).isInstanceOf(AvroRowDataDeserializationSchema.class);
+ }
+
+ @Test
+ void testSerializationSchemaWithRecordNameOverride() {
+ final String customRecordName = "MyCustomRecord";
+ final Map options =
+ getModifiedOptions(
+ opts -> opts.put("avro-glue.avro.record-name", customRecordName));
+
+ final DynamicTableSink actualSink = createTableSink(SCHEMA, options);
+ assertThat(actualSink).isInstanceOf(TestDynamicTableFactory.DynamicTableSinkMock.class);
+
+ TestDynamicTableFactory.DynamicTableSinkMock sinkMock =
+ (TestDynamicTableFactory.DynamicTableSinkMock) actualSink;
+
+ SerializationSchema actualSer =
+ sinkMock.valueFormat.createRuntimeEncoder(null, SCHEMA.toPhysicalRowDataType());
+
+ // Verify the schema is created successfully with record name override
+ assertThat(actualSer).isInstanceOf(AvroRowDataSerializationSchema.class);
+ }
+
+ @Test
+ void testSpiDiscovery() {
+ final DynamicTableSource source = createTableSource(SCHEMA, getDefaultOptions());
+ assertThat(source).isNotNull();
+
+ final DynamicTableSink sink = createTableSink(SCHEMA, getDefaultOptions());
+ assertThat(sink).isNotNull();
+ }
+
+ @Test
+ void testDeserializationSchemaWithFetchFromRegistry() {
+ final Map options =
+ getModifiedOptions(opts -> opts.put("avro-glue.schema.fetchFromRegistry", "true"));
+
+ // Should not throw - the option should be accepted
+ final DynamicTableSource actualSource = createTableSource(SCHEMA, options);
+ assertThat(actualSource).isInstanceOf(TestDynamicTableFactory.DynamicTableSourceMock.class);
+
+ TestDynamicTableFactory.DynamicTableSourceMock scanSourceMock =
+ (TestDynamicTableFactory.DynamicTableSourceMock) actualSource;
+
+ DeserializationSchema actualDeser =
+ scanSourceMock.valueFormat.createRuntimeDecoder(
+ ScanRuntimeProviderContext.INSTANCE, SCHEMA.toPhysicalRowDataType());
+
+ assertThat(actualDeser).isInstanceOf(AvroRowDataDeserializationSchema.class);
+ }
+
+ @Test
+ void testSerializationSchemaWithFetchFromRegistry() {
+ final Map options =
+ getModifiedOptions(opts -> opts.put("avro-glue.schema.fetchFromRegistry", "true"));
+
+ // Should not throw - the option should be accepted
+ final DynamicTableSink actualSink = createTableSink(SCHEMA, options);
+ assertThat(actualSink).isInstanceOf(TestDynamicTableFactory.DynamicTableSinkMock.class);
+
+ TestDynamicTableFactory.DynamicTableSinkMock sinkMock =
+ (TestDynamicTableFactory.DynamicTableSinkMock) actualSink;
+
+ SerializationSchema actualSer =
+ sinkMock.valueFormat.createRuntimeEncoder(null, SCHEMA.toPhysicalRowDataType());
+
+ assertThat(actualSer).isInstanceOf(AvroRowDataSerializationSchema.class);
+ }
+
+ @Test
+ void testDeserializationSchemaWithAllOverrideOptions() {
+ final Map options =
+ getModifiedOptions(
+ opts -> {
+ opts.put("avro-glue.avro.namespace", "com.example.custom");
+ opts.put("avro-glue.avro.record-name", "CustomRecord");
+ opts.put("avro-glue.schema.fetchFromRegistry", "false");
+ });
+
+ final DynamicTableSource actualSource = createTableSource(SCHEMA, options);
+ assertThat(actualSource).isInstanceOf(TestDynamicTableFactory.DynamicTableSourceMock.class);
+
+ TestDynamicTableFactory.DynamicTableSourceMock scanSourceMock =
+ (TestDynamicTableFactory.DynamicTableSourceMock) actualSource;
+
+ DeserializationSchema actualDeser =
+ scanSourceMock.valueFormat.createRuntimeDecoder(
+ ScanRuntimeProviderContext.INSTANCE, SCHEMA.toPhysicalRowDataType());
+
+ assertThat(actualDeser).isInstanceOf(AvroRowDataDeserializationSchema.class);
+ }
+
+ // ------------------------------------------------------------------------
+ // Utilities
+ // ------------------------------------------------------------------------
+
+ private Map getModifiedOptions(Consumer