diff --git a/cassandra-core/build.gradle.kts b/cassandra-core/build.gradle.kts
new file mode 100644
index 0000000000..202f86b3d4
--- /dev/null
+++ b/cassandra-core/build.gradle.kts
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("pulsar-connectors.java-conventions")
+}
+dependencies {
+ // Deliberately not a NAR module. nar-conventions disables the jar task and replaces this
+ // project's outgoing artifacts with the NAR itself, so a NAR module cannot be depended on — the
+ // consumer would bundle a .nar inside META-INF/bundled-dependencies where the classloader cannot
+ // reach the classes. The shared sink machinery therefore lives here, in a plain jar that the
+ // `cassandra`, `cassandra-generic-record` and `cassandra-json` NARs each depend on. This mirrors
+ // jdbc/core and its per-database NAR modules.
+ api(libs.pulsar.io.core)
+ api(libs.pulsar.io.common)
+ api(libs.pulsar.client.api)
+ api(libs.cassandra.driver)
+ implementation(libs.jackson.databind)
+ implementation(libs.jackson.dataformat.yaml)
+ implementation(libs.commons.beanutils)
+
+ testImplementation(libs.testcontainers.cassandra)
+ // Test only: builds the Avro-backed GenericRecord that CassandraGenericRecordSink consumes, the
+ // same way the hbase and jdbc sink tests do.
+ testImplementation(libs.pulsar.client)
+}
diff --git a/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraGenericRecordSink.java b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraGenericRecordSink.java
new file mode 100644
index 0000000000..9cde40dc40
--- /dev/null
+++ b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraGenericRecordSink.java
@@ -0,0 +1,48 @@
+/*
+ * 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.pulsar.io.cassandra;
+
+import org.apache.pulsar.client.api.schema.GenericRecord;
+import org.apache.pulsar.functions.api.Record;
+import org.apache.pulsar.io.cassandra.util.GenericRecordWrapper;
+import org.apache.pulsar.io.cassandra.util.RecordWrapper;
+import org.apache.pulsar.io.core.annotations.Connector;
+import org.apache.pulsar.io.core.annotations.IOType;
+
+/**
+ * Cassandra sink for topics carrying a schema, e.g. Avro or JSON with a registered schema.
+ *
+ *
Each field of the incoming {@link GenericRecord} is matched by name against a column of
+ * the target table, so the table definition decides what is written rather than a configured
+ * key/value column pair.
+ */
+@Connector(
+ name = "cassandra-generic-record",
+ type = IOType.SINK,
+ help = "Writes schema-carrying records to Cassandra, mapping record fields onto table columns by name. "
+ + "The target table's columns must all be of type text, varchar, ascii, int, double, float or boolean; "
+ + "any other column type is rejected when the sink starts.",
+ configClass = CassandraSinkConfig.class)
+public class CassandraGenericRecordSink extends CassandraTableSink {
+
+ @Override
+ RecordWrapper wrapRecord(Record record) {
+ return new GenericRecordWrapper(record.getValue());
+ }
+}
diff --git a/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraJsonStringSink.java b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraJsonStringSink.java
new file mode 100644
index 0000000000..1350bbbf88
--- /dev/null
+++ b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraJsonStringSink.java
@@ -0,0 +1,51 @@
+/*
+ * 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.pulsar.io.cassandra;
+
+import org.apache.pulsar.functions.api.Record;
+import org.apache.pulsar.io.cassandra.util.RecordWrapper;
+import org.apache.pulsar.io.cassandra.util.StringRecordWrapper;
+import org.apache.pulsar.io.core.annotations.Connector;
+import org.apache.pulsar.io.core.annotations.IOType;
+
+/**
+ * Cassandra sink for topics carrying raw JSON strings with no registered schema.
+ *
+ * Each message value is parsed as a JSON object and its top-level fields are matched by
+ * name against the columns of the target table.
+ *
+ *
This is distinct from the {@code cassandra} sink ({@link CassandraStringSink}), which
+ * treats the message as an opaque string and writes it to a single configured column. A
+ * deployment wanting JSON-to-column mapping has to select this sink explicitly; the
+ * behaviour of the existing {@code cassandra} sink is unchanged.
+ */
+@Connector(
+ name = "cassandra-json",
+ type = IOType.SINK,
+ help = "Writes raw JSON string messages to Cassandra, mapping top-level JSON fields onto table columns by name. "
+ + "The target table's columns must all be of type text, varchar, ascii, int, double, float or boolean; "
+ + "any other column type is rejected when the sink starts.",
+ configClass = CassandraSinkConfig.class)
+public class CassandraJsonStringSink extends CassandraTableSink {
+
+ @Override
+ RecordWrapper wrapRecord(Record record) {
+ return new StringRecordWrapper(record.getValue());
+ }
+}
diff --git a/cassandra/src/main/java/org/apache/pulsar/io/cassandra/CassandraSinkConfig.java b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraSinkConfig.java
similarity index 84%
rename from cassandra/src/main/java/org/apache/pulsar/io/cassandra/CassandraSinkConfig.java
rename to cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraSinkConfig.java
index b83af7a4c8..eade6db2d7 100644
--- a/cassandra/src/main/java/org/apache/pulsar/io/cassandra/CassandraSinkConfig.java
+++ b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraSinkConfig.java
@@ -60,10 +60,15 @@ public class CassandraSinkConfig implements Serializable {
defaultValue = "",
help = "The key space used for writing pulsar messages to")
private String keyspace;
+ // Not required = true: that is enforced at load time for every sink sharing this config, and the
+ // table-mapping sinks have no use for it. `cassandra` still requires it, and checks so itself in
+ // CassandraAbstractSink.open().
@FieldDoc(
- required = true,
+ required = false,
defaultValue = "",
- help = "The key name of the cassandra column family")
+ help = "The key name of the cassandra column family. Required by the `cassandra` sink. "
+ + "Unused by `cassandra-generic-record` and `cassandra-json`, which map record "
+ + "fields onto columns by name.")
private String keyname;
@FieldDoc(
required = true,
@@ -71,9 +76,11 @@ public class CassandraSinkConfig implements Serializable {
help = "The cassandra column family name")
private String columnFamily;
@FieldDoc(
- required = true,
+ required = false,
defaultValue = "",
- help = "The column name of the cassandra column family")
+ help = "The column name of the cassandra column family. Required by the `cassandra` sink. "
+ + "Unused by `cassandra-generic-record` and `cassandra-json`, which map record "
+ + "fields onto columns by name.")
private String columnName;
public static CassandraSinkConfig load(String yamlFile) throws IOException {
diff --git a/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraTableSink.java b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraTableSink.java
new file mode 100644
index 0000000000..dc3d00897a
--- /dev/null
+++ b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/CassandraTableSink.java
@@ -0,0 +1,151 @@
+/*
+ * 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.pulsar.io.cassandra;
+
+import com.datastax.driver.core.BoundStatement;
+import com.datastax.driver.core.PreparedStatement;
+import com.datastax.driver.core.ResultSet;
+import com.datastax.driver.core.ResultSetFuture;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pulsar.functions.api.Record;
+import org.apache.pulsar.io.cassandra.util.BoundStatementProvider;
+import org.apache.pulsar.io.cassandra.util.CassandraConnector;
+import org.apache.pulsar.io.cassandra.util.RecordWrapper;
+import org.apache.pulsar.io.cassandra.util.TableMetadataProvider;
+import org.apache.pulsar.io.common.IOConfigUtils;
+import org.apache.pulsar.io.core.Sink;
+import org.apache.pulsar.io.core.SinkContext;
+
+/**
+ * Base class for Cassandra sinks that map a structured record onto the columns of the
+ * target table.
+ *
+ * Unlike {@link CassandraAbstractSink}, which writes a fixed key/value column pair named
+ * by {@code keyname} and {@code columnName}, this base reads the table definition from the
+ * cluster metadata and binds every column it can find a matching field for. Subclasses only
+ * have to say how to read a field out of their record type, by returning the appropriate
+ * {@link RecordWrapper}.
+ *
+ *
{@link CassandraAbstractSink} and its {@code cassandra} sink are deliberately left
+ * alone: their key/value behaviour is what existing deployments are configured against.
+ */
+@Slf4j
+public abstract class CassandraTableSink implements Sink {
+
+ CassandraConnector connector;
+ CassandraSinkConfig cassandraSinkConfig;
+ PreparedStatement stmt;
+ BoundStatementProvider boundStatementProvider;
+
+ @Override
+ public void open(Map config, SinkContext ctx) throws Exception {
+
+ cassandraSinkConfig = IOConfigUtils.loadWithSecrets(config, CassandraSinkConfig.class, ctx);
+
+ if (cassandraSinkConfig.getRoots() == null
+ || cassandraSinkConfig.getKeyspace() == null
+ || cassandraSinkConfig.getColumnFamily() == null) {
+ throw new IllegalArgumentException("Required property not set.");
+ }
+ cassandraSinkConfig.validateCredentials();
+
+ connector = new CassandraConnector(cassandraSinkConfig);
+ connector.connect();
+
+ TableMetadataProvider.TableDefinition table = TableMetadataProvider.getTableDefinition(
+ connector.getTableMetadata(),
+ cassandraSinkConfig.getKeyspace(),
+ cassandraSinkConfig.getColumnFamily());
+ rejectUnsupportedColumnTypes(table);
+ boundStatementProvider = new BoundStatementProvider(table);
+ }
+
+ @Override
+ public void write(Record record) throws Exception {
+
+ BoundStatement bs;
+ try {
+ bs = boundStatementProvider.bindStatement(getStatement(), wrapRecord(record));
+ } catch (Exception e) {
+ // Everything before the statement is handed to the driver can fail on the record's own
+ // content: malformed JSON, a null value, a field that will not coerce to its column's type.
+ // Without this the exception leaves write() having neither acked nor failed the record, so
+ // the sink dies, Pulsar redelivers the same message, and one bad message becomes a restart
+ // loop. Failing it explicitly keeps the poison message the broker's problem, not ours.
+ log.error("Discarding record that could not be bound to {}.{}",
+ cassandraSinkConfig.getKeyspace(), cassandraSinkConfig.getColumnFamily(), e);
+ record.fail();
+ return;
+ }
+
+ ResultSetFuture future = connector.getSession().executeAsync(bs);
+
+ Futures.addCallback(future,
+ new FutureCallback() {
+ @Override
+ public void onSuccess(ResultSet result) {
+ record.ack();
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ record.fail();
+ }
+ }, MoreExecutors.directExecutor());
+ }
+
+ @Override
+ public void close() {
+ if (connector != null) {
+ connector.close();
+ }
+ }
+
+ /**
+ * Refuses a table holding a column this sink cannot bind a value to. Every column is a candidate
+ * for every record, so one unsupported column means writes fail — either on every record, or, for
+ * the integer widths, on whichever records happen to carry a value of the wrong magnitude. Saying
+ * so at {@code open()} names the column and its type once, instead of leaving an operator to read
+ * an {@code InvalidTypeException} per message.
+ */
+ private void rejectUnsupportedColumnTypes(TableMetadataProvider.TableDefinition table) {
+ for (TableMetadataProvider.ColumnId column : table.getColumns()) {
+ if (!RecordWrapper.supports(column.getType())) {
+ throw new IllegalArgumentException("Column '" + column.getName() + "' of "
+ + cassandraSinkConfig.getKeyspace() + "." + cassandraSinkConfig.getColumnFamily()
+ + " has type '" + column.getType()
+ + "', which this sink cannot map a record field onto. Supported column types: "
+ + RecordWrapper.supportedColumnTypes() + ".");
+ }
+ }
+ }
+
+ abstract RecordWrapper wrapRecord(Record record);
+
+ PreparedStatement getStatement() {
+ if (stmt == null) {
+ stmt = connector.getPreparedStatement();
+ }
+ return stmt;
+ }
+}
diff --git a/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/BoundStatementProvider.java b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/BoundStatementProvider.java
new file mode 100644
index 0000000000..db8b9f12a9
--- /dev/null
+++ b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/BoundStatementProvider.java
@@ -0,0 +1,61 @@
+/*
+ * 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.pulsar.io.cassandra.util;
+
+import com.datastax.driver.core.BoundStatement;
+import com.datastax.driver.core.PreparedStatement;
+
+@SuppressWarnings("rawtypes")
+public class BoundStatementProvider {
+ final TableMetadataProvider.TableDefinition tableDefinition;
+
+ public BoundStatementProvider(TableMetadataProvider.TableDefinition tableDefinition) {
+ this.tableDefinition = tableDefinition;
+ }
+
+ public BoundStatement bindStatement(PreparedStatement stmt, RecordWrapper wrapper) {
+ Object[] boundValues = new Object[tableDefinition.getColumns().size()];
+ boolean[] supplied = new boolean[boundValues.length];
+ int idx = 0;
+
+ for (TableMetadataProvider.ColumnId column : tableDefinition.getColumns()) {
+ if (wrapper.containsKey(column.getName())) {
+ // A field the record carries as null stays an explicit null: saying a column has no
+ // value is not the same as not mentioning the column.
+ boundValues[idx] = wrapper.get(column);
+ supplied[idx] = true;
+ }
+ idx++;
+ }
+
+ BoundStatement bound = stmt.bind(boundValues);
+ // bind(Object...) writes a real null into every position the record had no field for, and in
+ // Cassandra a null is a deletion: re-writing a row would erase the columns this record says
+ // nothing about, and every insert would leave a tombstone per absent column for compaction to
+ // deal with. Unsetting those positions restores "leave whatever is there alone", which is what
+ // mapping fields onto the columns they match should mean.
+ for (int i = 0; i < supplied.length; i++) {
+ if (!supplied[i]) {
+ bound.unset(i);
+ }
+ }
+ return bound;
+ }
+
+}
diff --git a/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/CassandraConnector.java b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/CassandraConnector.java
new file mode 100644
index 0000000000..3e1609a80a
--- /dev/null
+++ b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/CassandraConnector.java
@@ -0,0 +1,147 @@
+/*
+ * 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.pulsar.io.cassandra.util;
+
+import com.datastax.driver.core.Cluster;
+import com.datastax.driver.core.ColumnMetadata;
+import com.datastax.driver.core.Metadata;
+import com.datastax.driver.core.PreparedStatement;
+import com.datastax.driver.core.Session;
+import com.datastax.driver.core.TableMetadata;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.pulsar.io.cassandra.CassandraSinkConfig;
+
+public class CassandraConnector implements AutoCloseable {
+
+ private Cluster cluster;
+ private Session session;
+ private PreparedStatement statement;
+ private List tableFields;
+ private final CassandraSinkConfig config;
+
+ public CassandraConnector(CassandraSinkConfig config) {
+ this.config = config;
+ }
+
+ public void connect() {
+ session = getCluster().connect(config.getKeyspace());
+ }
+
+ public synchronized Session getSession() {
+ if (session == null || session.isClosed()) {
+ this.connect();
+ }
+ return session;
+ }
+
+ public Metadata getTableMetadata() {
+ return getCluster().getMetadata();
+ }
+
+ public PreparedStatement getPreparedStatement() {
+ if (statement == null) {
+ List fields = getTableFields();
+
+ StringBuilder sb = new StringBuilder("INSERT INTO ")
+ .append(config.getKeyspace() + "." + config.getColumnFamily() + " (");
+
+ for (int idx = 0; idx < fields.size(); idx++) {
+ sb.append(fields.get(idx));
+ if (idx < fields.size() - 1) {
+ sb.append(", ");
+ }
+ }
+
+ sb.append(") VALUES (");
+
+ for (int idx = 0; idx < fields.size(); idx++) {
+ sb.append("?");
+ if (idx < fields.size() - 1) {
+ sb.append(", ");
+ }
+ }
+
+ sb.append(")");
+ statement = getSession().prepare(sb.toString());
+ }
+
+ return statement;
+ }
+
+ List getTableFields() {
+
+ if (tableFields == null) {
+ // Resolved through TableMetadataProvider so the column list backing the INSERT is the same
+ // one the binder positions values against, and so a bad keyspace/table names the setting
+ // that is wrong rather than surfacing as a NullPointerException.
+ TableMetadataProvider.TableDefinition table = TableMetadataProvider.getTableDefinition(
+ getCluster().getMetadata(), config.getKeyspace(), config.getColumnFamily());
+
+ tableFields = new ArrayList(table.getColumns().size());
+
+ for (TableMetadataProvider.ColumnId col : table.getColumns()) {
+ tableFields.add(col.getName());
+ }
+ }
+ return tableFields;
+ }
+
+ private synchronized Cluster getCluster() {
+ if (cluster == null) {
+ String[] hosts = config.getRoots().split(",");
+
+ Cluster.Builder builder = Cluster.builder().withoutMetrics();
+
+ for (int i = 0; i < hosts.length; ++i) {
+ String[] hostPort = hosts[i].split(":");
+ builder.addContactPoint(hostPort[0]);
+ if (hostPort.length > 1) {
+ builder.withPort(Integer.parseInt(hostPort[1]));
+ }
+ }
+
+ // Authenticate if credentials have been provided. The sink has already rejected a pair
+ // with only one half set, so this is asking whether to authenticate, not whether the
+ // configuration makes sense.
+ if (config.hasCredentials()) {
+ builder.withCredentials(
+ config.getUserName(),
+ config.getPassword()
+ );
+ }
+ cluster = builder.build();
+ }
+
+ return cluster;
+ }
+
+ public void close() {
+ // Guarded on the fields, not on getSession()/getCluster(): those create what they return, so
+ // closing after a failed open() would re-attempt the connection and throw a second exception
+ // out of close(), hiding the original cause — or succeed and leave behind a session this method
+ // has just opened in order to close it.
+ if (session != null && !session.isClosed()) {
+ session.close();
+ }
+ if (cluster != null && !cluster.isClosed()) {
+ cluster.close();
+ }
+ }
+}
diff --git a/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/GenericRecordWrapper.java b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/GenericRecordWrapper.java
new file mode 100644
index 0000000000..d177d9490f
--- /dev/null
+++ b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/GenericRecordWrapper.java
@@ -0,0 +1,51 @@
+/*
+ * 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.pulsar.io.cassandra.util;
+
+import org.apache.pulsar.client.api.schema.Field;
+import org.apache.pulsar.client.api.schema.GenericRecord;
+
+public class GenericRecordWrapper extends RecordWrapper {
+
+ public GenericRecordWrapper(GenericRecord value) {
+ super(value);
+ }
+
+ @Override
+ public Object get(TableMetadataProvider.ColumnId column) {
+ return getValueAsExpectedType(recordValue.getField(column.getName()), column);
+ }
+
+ @Override
+ public boolean containsKey(String name) {
+ // Ask the schema rather than the value. GenericAvroRecord.getField(name) delegates to Avro,
+ // which throws AvroRuntimeException for a name the schema does not carry instead of returning
+ // null. A table column the record has no field for is the ordinary case here — the table
+ // decides the column list, not the record — so this has to answer false, not fail the write.
+ // Iterated rather than streamed: this is asked once per table column for every record
+ // written, and a record's field list is short enough that the stream would cost more than
+ // the scan.
+ for (Field field : recordValue.getFields()) {
+ if (field.getName().equals(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/RecordWrapper.java b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/RecordWrapper.java
new file mode 100644
index 0000000000..f9c7e6eb6c
--- /dev/null
+++ b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/RecordWrapper.java
@@ -0,0 +1,88 @@
+/*
+ * 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.pulsar.io.cassandra.util;
+
+import com.datastax.driver.core.DataType;
+import java.util.EnumSet;
+import java.util.Locale;
+import java.util.stream.Collectors;
+import org.apache.commons.beanutils.converters.IntegerConverter;
+import org.apache.commons.beanutils.converters.NumberConverter;
+
+public abstract class RecordWrapper {
+
+ T recordValue;
+ NumberConverter converter = new IntegerConverter();
+
+ public RecordWrapper(T value) {
+ this.recordValue = value;
+ }
+
+ public abstract Object get(TableMetadataProvider.ColumnId column);
+
+ public abstract boolean containsKey(String name);
+
+ /**
+ * Column types this wrapper can reliably bind a JSON or Avro value to. The set is deliberately
+ * narrow. Types such as {@code timestamp}, {@code uuid}, {@code inet}, {@code decimal} and
+ * {@code varint} need driver-specific Java objects ({@code Date}, {@code UUID},
+ * {@code InetAddress}, {@code BigDecimal}, {@code BigInteger}) that neither Jackson nor Avro ever
+ * produces, and {@code bigint}/{@code smallint}/{@code tinyint} are worse than unsupported — they
+ * work or fail depending on the magnitude of the value, since Jackson decodes a small number as an
+ * {@code Integer}. Rejecting the table at {@code open()} beats an {@code InvalidTypeException} on
+ * every record, or on some records.
+ */
+ private static final EnumSet SUPPORTED_COLUMN_TYPES = EnumSet.of(
+ DataType.Name.TEXT,
+ DataType.Name.VARCHAR,
+ DataType.Name.ASCII,
+ DataType.Name.INT,
+ DataType.Name.DOUBLE,
+ DataType.Name.FLOAT,
+ DataType.Name.BOOLEAN);
+
+ public static boolean supports(DataType type) {
+ return SUPPORTED_COLUMN_TYPES.contains(type.getName());
+ }
+
+ public static String supportedColumnTypes() {
+ return SUPPORTED_COLUMN_TYPES.stream()
+ .map(name -> name.toString().toLowerCase(Locale.ROOT))
+ .collect(Collectors.joining(", "));
+ }
+
+ Object getValueAsExpectedType(Object value, TableMetadataProvider.ColumnId column) {
+ // A field that is present but null binds as null. Every branch below would otherwise fail on
+ // it: TEXT calls toString(), and the converters reject a null with no default configured.
+ if (value == null) {
+ return null;
+ }
+ switch (column.getType().getName()) {
+ case FLOAT: return converter.convert(Float.class, value);
+ case INT: return converter.convert(Integer.class, value);
+ case DOUBLE: return converter.convert(Double.class, value);
+ case TEXT:
+ case VARCHAR:
+ case ASCII: return value.toString();
+ default: return value;
+ }
+
+ }
+
+}
diff --git a/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/StringRecordWrapper.java b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/StringRecordWrapper.java
new file mode 100644
index 0000000000..45c3c50b7c
--- /dev/null
+++ b/cassandra-core/src/main/java/org/apache/pulsar/io/cassandra/util/StringRecordWrapper.java
@@ -0,0 +1,52 @@
+/*
+ * 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.pulsar.io.cassandra.util;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Map;
+
+public class StringRecordWrapper extends RecordWrapper {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final TypeReference