This is an automated email from the ASF dual-hosted git repository.

wchevreuil pushed a commit to branch HBASE-30189
in repository https://gitbox.apache.org/repos/asf/hbase-connectors.git


The following commit(s) were added to refs/heads/HBASE-30189 by this push:
     new 6d2198a  Port the JSON catalog parsing that maps HBase tables to Spark 
SQL schemas (#160)
6d2198a is described below

commit 6d2198ae89ec59fdb3ac706999ae6103afbc9e31
Author: Wellington Ramos Chevreuil <[email protected]>
AuthorDate: Fri Aug 28 10:13:30 2026 +0100

    Port the JSON catalog parsing that maps HBase tables to Spark SQL schemas 
(#160)
    
    Co-authored-by: Claude Code (claude-opus-4-6) <[email protected]>
    
    Signed-off-by: Peter Somogyi <[email protected]>
---
 spark4/hbase-spark4/pom.xml                        |   4 +
 .../hadoop/hbase/spark/SchemaConverters.scala      | 445 +++++++++++++++++++++
 .../spark/datasources/HBaseTableCatalog.scala      | 394 ++++++++++++++++++
 .../hadoop/hbase/spark/datasources/SerDes.scala    |  35 ++
 .../hadoop/hbase/spark/datasources/Utils.scala     | 118 ++++++
 5 files changed, 996 insertions(+)

diff --git a/spark4/hbase-spark4/pom.xml b/spark4/hbase-spark4/pom.xml
index ee9dff9..ee0c5c5 100644
--- a/spark4/hbase-spark4/pom.xml
+++ b/spark4/hbase-spark4/pom.xml
@@ -124,6 +124,10 @@
       <groupId>org.apache.yetus</groupId>
       <artifactId>audience-annotations</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.apache.avro</groupId>
+      <artifactId>avro</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.hbase</groupId>
       <artifactId>hbase-common</artifactId>
diff --git 
a/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/SchemaConverters.scala
 
b/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/SchemaConverters.scala
new file mode 100644
index 0000000..df1491b
--- /dev/null
+++ 
b/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/SchemaConverters.scala
@@ -0,0 +1,445 @@
+/*
+ * 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.hadoop.hbase.spark
+
+import java.io.ByteArrayInputStream
+import java.nio.ByteBuffer
+import java.sql.Timestamp
+import java.util
+import java.util.HashMap
+import org.apache.avro.{Schema, SchemaBuilder}
+import org.apache.avro.Schema.Type._
+import org.apache.avro.SchemaBuilder.BaseFieldTypeBuilder
+import org.apache.avro.SchemaBuilder.BaseTypeBuilder
+import org.apache.avro.SchemaBuilder.FieldAssembler
+import org.apache.avro.SchemaBuilder.FieldDefault
+import org.apache.avro.SchemaBuilder.RecordBuilder
+import org.apache.avro.generic.{GenericData, GenericDatumReader, 
GenericDatumWriter, GenericRecord}
+import org.apache.avro.generic.GenericData.{Fixed, Record}
+import org.apache.avro.io._
+import org.apache.commons.io.output.ByteArrayOutputStream
+import org.apache.hadoop.hbase.util.Bytes
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.types._
+import org.apache.yetus.audience.InterfaceAudience
+import scala.jdk.CollectionConverters._
+
[email protected]
+abstract class AvroException(msg: String) extends Exception(msg)
+
[email protected]
+case class SchemaConversionException(msg: String) extends AvroException(msg)
+
+/**
+ * *
+ * On top level, the converters provide three high level interface.
+ * 1. toSqlType: This function takes an avro schema and returns a sql schema.
+ * 2. createConverterToSQL: Returns a function that is used to convert avro 
types to their
+ *    corresponding sparkSQL representations.
+ * 3. convertTypeToAvro: This function constructs converter function for a 
given sparkSQL
+ *    datatype. This is used in writing Avro records out to disk
+ */
[email protected]
+object SchemaConverters {
+
+  case class SchemaType(dataType: DataType, nullable: Boolean)
+
+  /**
+   * This function takes an avro schema and returns a sql schema.
+   */
+  def toSqlType(avroSchema: Schema): SchemaType = {
+    avroSchema.getType match {
+      case INT => SchemaType(IntegerType, nullable = false)
+      case STRING => SchemaType(StringType, nullable = false)
+      case BOOLEAN => SchemaType(BooleanType, nullable = false)
+      case BYTES => SchemaType(BinaryType, nullable = false)
+      case DOUBLE => SchemaType(DoubleType, nullable = false)
+      case FLOAT => SchemaType(FloatType, nullable = false)
+      case LONG => SchemaType(LongType, nullable = false)
+      case FIXED => SchemaType(BinaryType, nullable = false)
+      case ENUM => SchemaType(StringType, nullable = false)
+
+      case RECORD =>
+        val fields = avroSchema.getFields.asScala.map { f =>
+          val schemaType = toSqlType(f.schema())
+          StructField(f.name, schemaType.dataType, schemaType.nullable)
+        }
+        SchemaType(StructType(fields.toSeq), nullable = false)
+
+      case ARRAY =>
+        val schemaType = toSqlType(avroSchema.getElementType)
+        SchemaType(
+          ArrayType(schemaType.dataType, containsNull = schemaType.nullable),
+          nullable = false)
+
+      case MAP =>
+        val schemaType = toSqlType(avroSchema.getValueType)
+        SchemaType(
+          MapType(StringType, schemaType.dataType, valueContainsNull = 
schemaType.nullable),
+          nullable = false)
+
+      case UNION =>
+        if (avroSchema.getTypes.asScala.exists(_.getType == NULL)) {
+          // In case of a union with null, eliminate it and make a recursive 
call
+          val remainingUnionTypes =
+            avroSchema.getTypes.asScala.filterNot(_.getType == NULL).asJava
+          if (remainingUnionTypes.size == 1) {
+            toSqlType(remainingUnionTypes.get(0)).copy(nullable = true)
+          } else {
+            toSqlType(Schema.createUnion(remainingUnionTypes)).copy(nullable = 
true)
+          }
+        } else
+          avroSchema.getTypes.asScala.map(_.getType).toSeq match {
+            case Seq(t1, t2) if Set(t1, t2) == Set(INT, LONG) =>
+              SchemaType(LongType, nullable = false)
+            case Seq(t1, t2) if Set(t1, t2) == Set(FLOAT, DOUBLE) =>
+              SchemaType(DoubleType, nullable = false)
+            case other =>
+              throw new SchemaConversionException(
+                s"This mix of union types is not supported: $other")
+          }
+
+      case other => throw new SchemaConversionException(s"Unsupported type 
$other")
+    }
+  }
+
+  /**
+   * This function converts sparkSQL StructType into avro schema. This method 
uses two other
+   * converter methods in order to do the conversion.
+   */
+  private def convertStructToAvro[T](
+      structType: StructType,
+      schemaBuilder: RecordBuilder[T],
+      recordNamespace: String): T = {
+    val fieldsAssembler: FieldAssembler[T] = schemaBuilder.fields()
+    structType.fields.foreach { field =>
+      val newField = fieldsAssembler.name(field.name).`type`()
+
+      if (field.nullable) {
+        convertFieldTypeToAvro(
+          field.dataType,
+          newField.nullable(),
+          field.name,
+          recordNamespace).noDefault
+      } else {
+        convertFieldTypeToAvro(field.dataType, newField, field.name, 
recordNamespace).noDefault
+      }
+    }
+    fieldsAssembler.endRecord()
+  }
+
+  /**
+   * Returns a function that is used to convert avro types to their
+   * corresponding sparkSQL representations.
+   */
+  def createConverterToSQL(schema: Schema): Any => Any = {
+    schema.getType match {
+      case STRING | ENUM => (item: Any) => if (item == null) null else 
item.toString
+      case INT | BOOLEAN | DOUBLE | FLOAT | LONG => identity
+      case FIXED =>
+        (item: Any) =>
+          if (item == null) {
+            null
+          } else {
+            item.asInstanceOf[Fixed].bytes().clone()
+          }
+      case BYTES =>
+        (item: Any) =>
+          if (item == null) {
+            null
+          } else {
+            val bytes = item.asInstanceOf[ByteBuffer]
+            val javaBytes = new Array[Byte](bytes.remaining)
+            bytes.get(javaBytes)
+            javaBytes
+          }
+      case RECORD =>
+        val fieldConverters = schema.getFields.asScala.map(f => 
createConverterToSQL(f.schema))
+        (item: Any) =>
+          if (item == null) {
+            null
+          } else {
+            val record = item.asInstanceOf[GenericRecord]
+            val converted = new Array[Any](fieldConverters.size)
+            var idx = 0
+            while (idx < fieldConverters.size) {
+              converted(idx) = fieldConverters(idx)(record.get(idx))
+              idx += 1
+            }
+            Row.fromSeq(converted.toSeq)
+          }
+      case ARRAY =>
+        val elementConverter = createConverterToSQL(schema.getElementType)
+        (item: Any) =>
+          if (item == null) {
+            null
+          } else {
+            try {
+              
item.asInstanceOf[GenericData.Array[Any]].asScala.map(elementConverter).toSeq
+            } catch {
+              case _: Throwable =>
+                
item.asInstanceOf[util.ArrayList[Any]].asScala.map(elementConverter).toSeq
+            }
+          }
+      case MAP =>
+        val valueConverter = createConverterToSQL(schema.getValueType)
+        (item: Any) =>
+          if (item == null) {
+            null
+          } else {
+            item
+              .asInstanceOf[HashMap[Any, Any]]
+              .asScala
+              .map(x => (x._1.toString, valueConverter(x._2)))
+              .toMap
+          }
+      case UNION =>
+        if (schema.getTypes.asScala.exists(_.getType == NULL)) {
+          val remainingUnionTypes = 
schema.getTypes.asScala.filterNot(_.getType == NULL).asJava
+          if (remainingUnionTypes.size == 1) {
+            createConverterToSQL(remainingUnionTypes.get(0))
+          } else {
+            createConverterToSQL(Schema.createUnion(remainingUnionTypes))
+          }
+        } else
+          schema.getTypes.asScala.map(_.getType).toSeq match {
+            case Seq(t1, t2) if Set(t1, t2) == Set(INT, LONG) =>
+              (item: Any) => {
+                item match {
+                  case l: Long => l
+                  case i: Int => i.toLong
+                  case null => null
+                }
+              }
+            case Seq(t1, t2) if Set(t1, t2) == Set(FLOAT, DOUBLE) =>
+              (item: Any) => {
+                item match {
+                  case d: Double => d
+                  case f: Float => f.toDouble
+                  case null => null
+                }
+              }
+            case other =>
+              throw new SchemaConversionException(
+                s"This mix of union types is not supported (see README): 
$other")
+          }
+      case other => throw new SchemaConversionException(s"invalid avro type: 
$other")
+    }
+  }
+
+  /**
+   * This function is used to convert some sparkSQL type to avro type. Note 
that this function won't
+   * be used to construct fields of avro record (convertFieldTypeToAvro is 
used for that).
+   */
+  private def convertTypeToAvro[T](
+      dataType: DataType,
+      schemaBuilder: BaseTypeBuilder[T],
+      structName: String,
+      recordNamespace: String): T = {
+    dataType match {
+      case ByteType => schemaBuilder.intType()
+      case ShortType => schemaBuilder.intType()
+      case IntegerType => schemaBuilder.intType()
+      case LongType => schemaBuilder.longType()
+      case FloatType => schemaBuilder.floatType()
+      case DoubleType => schemaBuilder.doubleType()
+      case _: DecimalType => schemaBuilder.stringType()
+      case StringType => schemaBuilder.stringType()
+      case BinaryType => schemaBuilder.bytesType()
+      case BooleanType => schemaBuilder.booleanType()
+      case TimestampType => schemaBuilder.longType()
+
+      case ArrayType(elementType, _) =>
+        val builder = 
getSchemaBuilder(dataType.asInstanceOf[ArrayType].containsNull)
+        val elementSchema = convertTypeToAvro(elementType, builder, 
structName, recordNamespace)
+        schemaBuilder.array().items(elementSchema)
+
+      case MapType(StringType, valueType, _) =>
+        val builder = 
getSchemaBuilder(dataType.asInstanceOf[MapType].valueContainsNull)
+        val valueSchema = convertTypeToAvro(valueType, builder, structName, 
recordNamespace)
+        schemaBuilder.map().values(valueSchema)
+
+      case structType: StructType =>
+        convertStructToAvro(
+          structType,
+          schemaBuilder.record(structName).namespace(recordNamespace),
+          recordNamespace)
+
+      case other => throw new IllegalArgumentException(s"Unexpected type 
$dataType.")
+    }
+  }
+
+  /**
+   * This function is used to construct fields of the avro record, where 
schema of the field is
+   * specified by avro representation of dataType. Since builders for record 
fields are different
+   * from those for everything else, we have to use a separate method.
+   */
+  private def convertFieldTypeToAvro[T](
+      dataType: DataType,
+      newFieldBuilder: BaseFieldTypeBuilder[T],
+      structName: String,
+      recordNamespace: String): FieldDefault[T, _] = {
+    dataType match {
+      case ByteType => newFieldBuilder.intType()
+      case ShortType => newFieldBuilder.intType()
+      case IntegerType => newFieldBuilder.intType()
+      case LongType => newFieldBuilder.longType()
+      case FloatType => newFieldBuilder.floatType()
+      case DoubleType => newFieldBuilder.doubleType()
+      case _: DecimalType => newFieldBuilder.stringType()
+      case StringType => newFieldBuilder.stringType()
+      case BinaryType => newFieldBuilder.bytesType()
+      case BooleanType => newFieldBuilder.booleanType()
+      case TimestampType => newFieldBuilder.longType()
+
+      case ArrayType(elementType, _) =>
+        val builder = 
getSchemaBuilder(dataType.asInstanceOf[ArrayType].containsNull)
+        val elementSchema = convertTypeToAvro(elementType, builder, 
structName, recordNamespace)
+        newFieldBuilder.array().items(elementSchema)
+
+      case MapType(StringType, valueType, _) =>
+        val builder = 
getSchemaBuilder(dataType.asInstanceOf[MapType].valueContainsNull)
+        val valueSchema = convertTypeToAvro(valueType, builder, structName, 
recordNamespace)
+        newFieldBuilder.map().values(valueSchema)
+
+      case structType: StructType =>
+        convertStructToAvro(
+          structType,
+          newFieldBuilder.record(structName).namespace(recordNamespace),
+          recordNamespace)
+
+      case other => throw new IllegalArgumentException(s"Unexpected type 
$dataType.")
+    }
+  }
+
+  private def getSchemaBuilder(isNullable: Boolean): BaseTypeBuilder[Schema] = 
{
+    if (isNullable) {
+      SchemaBuilder.builder().nullable()
+    } else {
+      SchemaBuilder.builder()
+    }
+  }
+
+  /**
+   * This function constructs converter function for a given sparkSQL 
datatype. This is used in
+   * writing Avro records out to disk
+   */
+  def createConverterToAvro(
+      dataType: DataType,
+      structName: String,
+      recordNamespace: String): (Any) => Any = {
+    dataType match {
+      case BinaryType =>
+        (item: Any) =>
+          item match {
+            case null => null
+            case bytes: Array[Byte] => ByteBuffer.wrap(bytes)
+          }
+      case ByteType | ShortType | IntegerType | LongType | FloatType | 
DoubleType | StringType |
+          BooleanType =>
+        identity
+      case _: DecimalType => (item: Any) => if (item == null) null else 
item.toString
+      case TimestampType =>
+        (item: Any) => if (item == null) null else 
item.asInstanceOf[Timestamp].getTime
+      case ArrayType(elementType, _) =>
+        val elementConverter = createConverterToAvro(elementType, structName, 
recordNamespace)
+        (item: Any) => {
+          if (item == null) {
+            null
+          } else {
+            val sourceArray = item.asInstanceOf[Seq[Any]]
+            val sourceArraySize = sourceArray.size
+            val targetArray = new util.ArrayList[Any](sourceArraySize)
+            var idx = 0
+            while (idx < sourceArraySize) {
+              targetArray.add(elementConverter(sourceArray(idx)))
+              idx += 1
+            }
+            targetArray
+          }
+        }
+      case MapType(StringType, valueType, _) =>
+        val valueConverter = createConverterToAvro(valueType, structName, 
recordNamespace)
+        (item: Any) => {
+          if (item == null) {
+            null
+          } else {
+            val javaMap = new HashMap[String, Any]()
+            item.asInstanceOf[Map[String, Any]].foreach {
+              case (key, value) =>
+                javaMap.put(key, valueConverter(value))
+            }
+            javaMap
+          }
+        }
+      case structType: StructType =>
+        val builder = 
SchemaBuilder.record(structName).namespace(recordNamespace)
+        val schema: Schema =
+          SchemaConverters.convertStructToAvro(structType, builder, 
recordNamespace)
+        val fieldConverters = structType.fields.map(
+          field => createConverterToAvro(field.dataType, field.name, 
recordNamespace))
+        (item: Any) => {
+          if (item == null) {
+            null
+          } else {
+            val record = new Record(schema)
+            val convertersIterator = fieldConverters.iterator
+            val fieldNamesIterator = 
dataType.asInstanceOf[StructType].fieldNames.iterator
+            val rowIterator = item.asInstanceOf[Row].toSeq.iterator
+
+            while (convertersIterator.hasNext) {
+              val converter = convertersIterator.next()
+              record.put(fieldNamesIterator.next(), 
converter(rowIterator.next()))
+            }
+            record
+          }
+        }
+    }
+  }
+}
+
[email protected]
+object AvroSerdes {
+  // We only handle top level is record or primary type now
+  def serialize(input: Any, schema: Schema): Array[Byte] = {
+    schema.getType match {
+      case BOOLEAN => Bytes.toBytes(input.asInstanceOf[Boolean])
+      case BYTES | FIXED => input.asInstanceOf[Array[Byte]]
+      case DOUBLE => Bytes.toBytes(input.asInstanceOf[Double])
+      case FLOAT => Bytes.toBytes(input.asInstanceOf[Float])
+      case INT => Bytes.toBytes(input.asInstanceOf[Int])
+      case LONG => Bytes.toBytes(input.asInstanceOf[Long])
+      case STRING => Bytes.toBytes(input.asInstanceOf[String])
+      case RECORD =>
+        val gr = input.asInstanceOf[GenericRecord]
+        val writer2 = new GenericDatumWriter[GenericRecord](schema)
+        val bao2 = new ByteArrayOutputStream()
+        val encoder2: BinaryEncoder = 
EncoderFactory.get().directBinaryEncoder(bao2, null)
+        writer2.write(gr, encoder2)
+        bao2.toByteArray()
+      case _ => throw new Exception(s"unsupported data type ${schema.getType}")
+    }
+  }
+
+  def deserialize(input: Array[Byte], schema: Schema): GenericRecord = {
+    val reader2: DatumReader[GenericRecord] = new 
GenericDatumReader[GenericRecord](schema)
+    val bai2 = new ByteArrayInputStream(input)
+    val decoder2: BinaryDecoder = 
DecoderFactory.get().directBinaryDecoder(bai2, null)
+    reader2.read(null, decoder2)
+  }
+}
diff --git 
a/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/datasources/HBaseTableCatalog.scala
 
b/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/datasources/HBaseTableCatalog.scala
new file mode 100644
index 0000000..ddd9d49
--- /dev/null
+++ 
b/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/datasources/HBaseTableCatalog.scala
@@ -0,0 +1,394 @@
+/*
+ * 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.hadoop.hbase.spark.datasources
+
+import org.apache.avro.Schema
+import org.apache.hadoop.hbase.spark.{Logging, SchemaConverters}
+import org.apache.hadoop.hbase.util.Bytes
+import org.apache.spark.sql.types._
+import org.apache.yetus.audience.InterfaceAudience
+import org.json4s.DefaultFormats
+import org.json4s.Formats
+import org.json4s.jackson.JsonMethods
+import scala.collection.mutable
+import scala.jdk.CollectionConverters._
+
[email protected]
+case class Field(
+    colName: String,
+    cf: String,
+    col: String,
+    sType: Option[String] = None,
+    avroSchema: Option[String] = None,
+    serdes: Option[SerDes] = None,
+    len: Int = -1)
+    extends Logging {
+  override def toString: String = s"$colName $cf $col"
+  val isRowKey: Boolean = cf == HBaseTableCatalog.rowKey
+  var start: Int = _
+  def schema: Option[Schema] = avroSchema.map { x =>
+    logDebug(s"avro: $x")
+    val p = new Schema.Parser
+    p.parse(x)
+  }
+
+  lazy val exeSchema: Option[Schema] = schema
+
+  lazy val avroToCatalyst: Option[Any => Any] = {
+    schema.map(SchemaConverters.createConverterToSQL(_))
+  }
+
+  lazy val catalystToAvro: (Any) => Any = {
+    SchemaConverters.createConverterToAvro(dt, colName, "recordNamespace")
+  }
+
+  def cfBytes: Array[Byte] = {
+    if (isRowKey) {
+      Bytes.toBytes("")
+    } else {
+      Bytes.toBytes(cf)
+    }
+  }
+  def colBytes: Array[Byte] = {
+    if (isRowKey) {
+      Bytes.toBytes("key")
+    } else {
+      Bytes.toBytes(col)
+    }
+  }
+
+  val dt: DataType = {
+    sType.map(DataTypeParserWrapper.parse(_)).getOrElse {
+      schema.map { x => SchemaConverters.toSqlType(x).dataType }.get
+    }
+  }
+
+  var length: Int = {
+    if (len == -1) {
+      dt match {
+        case BinaryType | StringType => -1
+        case BooleanType => Bytes.SIZEOF_BOOLEAN
+        case ByteType => 1
+        case DoubleType => Bytes.SIZEOF_DOUBLE
+        case FloatType => Bytes.SIZEOF_FLOAT
+        case IntegerType => Bytes.SIZEOF_INT
+        case LongType => Bytes.SIZEOF_LONG
+        case ShortType => Bytes.SIZEOF_SHORT
+        case _ => -1
+      }
+    } else {
+      len
+    }
+  }
+
+  override def equals(other: Any): Boolean = other match {
+    case that: Field =>
+      colName == that.colName && cf == that.cf && col == that.col
+    case _ => false
+  }
+}
+
[email protected]
+case class RowKey(k: String) {
+  val keys: Array[String] = k.split(":")
+  var fields: Seq[Field] = _
+  var varLength = false
+  def length: Int = {
+    if (varLength) {
+      -1
+    } else {
+      fields.foldLeft(0) { case (x, y) => x + y.length }
+    }
+  }
+}
+
[email protected]
+case class SchemaMap(map: mutable.HashMap[String, Field]) {
+  def toFields: Seq[StructField] = map.map {
+    case (name, field) =>
+      StructField(name, field.dt)
+  }.toSeq
+
+  def fields: Iterable[Field] = map.values
+
+  def getField(name: String): Field = map(name)
+}
+
[email protected]
+case class HBaseTableCatalog(
+    namespace: String,
+    name: String,
+    row: RowKey,
+    sMap: SchemaMap,
+    @transient params: Map[String, String])
+    extends Logging {
+  def toDataType: StructType = StructType(sMap.toFields)
+  def getField(name: String): Field = sMap.getField(name)
+  def getRowKey: Seq[Field] = row.fields
+  def getPrimaryKey: String = row.keys(0)
+  def getColumnFamilies: Seq[String] = {
+    sMap.fields.map(_.cf).filter(_ != HBaseTableCatalog.rowKey).toSeq.distinct
+  }
+
+  def get(key: String): Option[String] = params.get(key)
+
+  // Setup the start and length for each dimension of row key at runtime.
+  def dynSetupRowKey(rowKey: Array[Byte]): Unit = {
+    logDebug(s"length: ${rowKey.length}")
+    if (row.varLength) {
+      var start = 0
+      row.fields.foreach { f =>
+        logDebug(s"start: $start")
+        f.start = start
+        f.length = {
+          // If the length is not defined
+          if (f.length == -1) {
+            f.dt match {
+              case StringType =>
+                var pos = rowKey.indexOf(HBaseTableCatalog.delimiter, start)
+                if (pos == -1 || pos > rowKey.length) {
+                  pos = rowKey.length
+                }
+                pos - start
+              // We don't know the length, assume it extend to the end of the 
rowkey.
+              case _ => rowKey.length - start
+            }
+          } else {
+            f.length
+          }
+        }
+        start += f.length
+      }
+    }
+  }
+
+  def initRowKey: Unit = {
+    val fields = sMap.fields.filter(_.cf == HBaseTableCatalog.rowKey)
+    row.fields = row.keys.flatMap(n => fields.find(_.col == n)).toSeq
+    // The length is determined at run time if it is string or binary and the 
length is undefined.
+    if (row.fields.filter(_.length == -1).isEmpty) {
+      var start = 0
+      row.fields.foreach { f =>
+        f.start = start
+        start += f.length
+      }
+    } else {
+      row.varLength = true
+    }
+  }
+  initRowKey
+}
+
+/**
+ * Directly ported from spark3 compatible module, this is intended for 
internal use in spark 4.
+ * With DataSource V2, the entry point is always a TableProvider — that's the 
class Spark instantiates
+ * when you call .format(...). It's the V2 equivalent of what DefaultSource (a 
RelationProvider) was in V1.
+ *
+ * The flow is:
+ *  1. .format("org.apache.hadoop.hbase.spark.datasources.HBaseTableProvider") 
— Spark instantiates this class
+ *  2. HBaseTableProvider reads the "catalog" option and parses it via 
HBaseTableCatalog(properties) internally
+ *  3. It returns an HBaseTable, which returns an HBaseScanBuilder, and so on 
down the chain
+ *
+ *  For example, in spark 3, client code looks like:
+ *  <code>
+ *    val catalog = "..." // The actual json catalog string
+ *    ...
+ *    val df = spark.read.option(HBaseTableCatalog.tableCatalog, catalog)
+ *      .format("org.apache.hadoop.hbase.spark")
+ *      .load()
+ *    ...
+ *  </code>
+ *
+ *  Whilst in spark 4, client code looks like:
+ *  <code>
+ *    val catalog = "..." // The actual json catalog string
+ *    ...
+ *    val df = 
spark.read.format("org.apache.hadoop.hbase.spark.datasources.HBaseTableProvider")
+ *    .option("catalog", catalog)
+ *    ...
+ *  </code>
+ */
[email protected]
+object HBaseTableCatalog {
+  val newTable = "newtable"
+  val regionStart = "regionStart"
+  val defaultRegionStart = "aaaaaaa"
+  val regionEnd = "regionEnd"
+  val defaultRegionEnd = "zzzzzzz"
+  val tableCatalog = "catalog"
+  val rowKey = "rowkey"
+  val table = "table"
+  val nameSpace = "namespace"
+  val tableName = "name"
+  val columns = "columns"
+  val cf = "cf"
+  val col = "col"
+  val `type` = "type"
+  val avro = "avro"
+  val delimiter: Byte = 0
+  val serdes = "serdes"
+  val length = "length"
+
+  /**
+   * User provide table schema definition
+   * {"tablename":"name", "rowkey":"key1:key2",
+   * "columns":{"col1":{"cf":"cf1", "col":"col1", "type":"type1"},
+   * "col2":{"cf":"cf2", "col":"col2", "type":"type2"}}}
+   * Note that any col in the rowKey, there has to be one corresponding col 
defined in columns
+   */
+  def apply(params: Map[String, String]): HBaseTableCatalog = {
+    val parameters = convert(params)
+    val jString = parameters(tableCatalog)
+    implicit val formats: Formats = DefaultFormats
+    val map = JsonMethods.parse(jString)
+    val tableMeta = map \ table
+    val nSpace = (tableMeta \ nameSpace).extractOrElse("default")
+    val tName = (tableMeta \ tableName).extract[String]
+    val cIter = (map \ columns).extract[Map[String, Map[String, String]]]
+    val schemaMap = mutable.HashMap.empty[String, Field]
+    cIter.foreach {
+      case (name, column) =>
+        val sd = {
+          column
+            .get(serdes)
+            .asInstanceOf[Option[String]]
+            .map(n => 
Class.forName(n).getDeclaredConstructor().newInstance().asInstanceOf[SerDes])
+        }
+        val len = column.get(length).map(_.toInt).getOrElse(-1)
+        val sAvro = column.get(avro).map(parameters(_))
+        val f = Field(
+          name,
+          column.getOrElse(cf, rowKey),
+          column.get(col).get,
+          column.get(`type`),
+          sAvro,
+          sd,
+          len)
+        schemaMap.+=((name, f))
+    }
+    val rKey = RowKey((map \ rowKey).extract[String])
+    HBaseTableCatalog(nSpace, tName, rKey, SchemaMap(schemaMap), parameters)
+  }
+
+  val TABLE_KEY: String = "hbase.table"
+  val SCHEMA_COLUMNS_MAPPING_KEY: String = "hbase.columns.mapping"
+
+  //Directly ported from spark 3 compatible module, which was marking it as 
deprecated, but since this is
+  // used by apply(), had made it private access.
+  private def convert(parameters: Map[String, String]): Map[String, String] = {
+    val nsTableName = parameters.get(TABLE_KEY).orNull
+    // if the hbase.table is not defined, we assume it is json format already.
+    if (nsTableName == null) return parameters
+    val tableParts = nsTableName.trim.split(':')
+    val tableNamespace = if (tableParts.length == 1) {
+      "default"
+    } else if (tableParts.length == 2) {
+      tableParts(0)
+    } else {
+      throw new IllegalArgumentException(
+        "Invalid table name '" + nsTableName +
+          "' should be '<namespace>:<name>' or '<name>' ")
+    }
+    val tblName = tableParts(tableParts.length - 1)
+    val schemaMappingString = parameters.getOrElse(SCHEMA_COLUMNS_MAPPING_KEY, 
"")
+    val schemaMap = generateSchemaMappingMap(schemaMappingString).asScala.map(
+      _._2.asInstanceOf[SchemaQualifierDefinition])
+
+    val rowkey = schemaMap
+      .filter {
+        _.columnFamily == "rowkey"
+      }
+      .map(_.columnName)
+    val cols = schemaMap.map { x =>
+      s""""${x.columnName}":{"cf":"${x.columnFamily}", "col":"${x.qualifier}", 
"type":"${x.colType}"}""".stripMargin
+    }
+    val jsonCatalog =
+      s"""{
+         |"table":{"namespace":"${tableNamespace}", "name":"${tblName}"},
+         |"rowkey":"${rowkey.mkString(":")}",
+         |"columns":{
+         |${cols.mkString(",")}
+         |}
+         |}
+       """.stripMargin
+    parameters ++ Map(HBaseTableCatalog.tableCatalog -> jsonCatalog)
+  }
+
+  /**
+   * Reads the SCHEMA_COLUMNS_MAPPING_KEY and converts it to a map of
+   * SchemaQualifierDefinitions with the original sql column name as the key
+   *
+   * @param schemaMappingString The schema mapping string from the SparkSQL map
+   * @return A map of definitions keyed by the SparkSQL column name
+   */
+  @InterfaceAudience.Private
+  def generateSchemaMappingMap(
+      schemaMappingString: String): java.util.HashMap[String, 
SchemaQualifierDefinition] = {
+    try {
+      val columnDefinitions = schemaMappingString.split(',')
+      val resultingMap = new java.util.HashMap[String, 
SchemaQualifierDefinition]()
+      columnDefinitions.map { cd =>
+        val parts = cd.trim.split(' ')
+        // Make sure we get three parts
+        // <ColumnName> <ColumnType> <ColumnFamily:Qualifier>
+        if (parts.length == 3) {
+          val hbaseDefinitionParts = if (parts(2).charAt(0) == ':') {
+            Array[String]("rowkey", parts(0))
+          } else {
+            parts(2).split(':')
+          }
+          resultingMap.put(
+            parts(0),
+            new SchemaQualifierDefinition(
+              parts(0),
+              parts(1),
+              hbaseDefinitionParts(0),
+              hbaseDefinitionParts(1)))
+        } else {
+          throw new IllegalArgumentException(
+            "Invalid value for schema mapping '" + cd +
+              "' should be '<columnName> <columnType> 
<columnFamily>:<qualifier>' " +
+              "for columns and '<columnName> <columnType> :<qualifier>' for 
rowKeys")
+        }
+      }
+      resultingMap
+    } catch {
+      case e: Exception =>
+        throw new IllegalArgumentException(
+          "Invalid value for " + SCHEMA_COLUMNS_MAPPING_KEY +
+            " '" +
+            schemaMappingString + "'",
+          e)
+    }
+  }
+}
+
+/**
+ * Construct to contains column data that spend SparkSQL and HBase
+ *
+ * @param columnName   SparkSQL column name
+ * @param colType      SparkSQL column type
+ * @param columnFamily HBase column family
+ * @param qualifier    HBase qualifier name
+ */
[email protected]
+case class SchemaQualifierDefinition(
+    columnName: String,
+    colType: String,
+    columnFamily: String,
+    qualifier: String)
diff --git 
a/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/datasources/SerDes.scala
 
b/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/datasources/SerDes.scala
new file mode 100644
index 0000000..bf01901
--- /dev/null
+++ 
b/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/datasources/SerDes.scala
@@ -0,0 +1,35 @@
+/*
+ * 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.hadoop.hbase.spark.datasources
+
+import org.apache.hadoop.hbase.util.Bytes
+import org.apache.yetus.audience.InterfaceAudience
+
[email protected]
+trait SerDes {
+  def serialize(value: Any): Array[Byte]
+  def deserialize(bytes: Array[Byte], start: Int, end: Int): Any
+}
+
[email protected]
+class DoubleSerDes extends SerDes {
+  override def serialize(value: Any): Array[Byte] = 
Bytes.toBytes(value.asInstanceOf[Double])
+  override def deserialize(bytes: Array[Byte], start: Int, end: Int): Any = {
+    Bytes.toDouble(bytes, start)
+  }
+}
diff --git 
a/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/datasources/Utils.scala
 
b/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/datasources/Utils.scala
new file mode 100644
index 0000000..ea6f3eb
--- /dev/null
+++ 
b/spark4/hbase-spark4/src/main/scala/org/apache/hadoop/hbase/spark/datasources/Utils.scala
@@ -0,0 +1,118 @@
+/*
+ * 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.hadoop.hbase.spark.datasources
+
+import java.sql.{Date, Timestamp}
+import org.apache.hadoop.hbase.spark.AvroSerdes
+import org.apache.hadoop.hbase.util.Bytes
+import org.apache.spark.sql.types._
+import org.apache.yetus.audience.InterfaceAudience
+
+/**
+ * Parses the hbase field to it's corresponding
+ * scala type which can then be put into a Spark GenericRow
+ * which is then automatically converted by Spark.
+ */
[email protected]
+object Utils {
+
+  def hbaseFieldToScalaType(f: Field, src: Array[Byte], offset: Int, length: 
Int): Any = {
+    if (f.exeSchema.isDefined) {
+      // If we have avro schema defined, use it to get record, and then 
convert them to catalyst data type
+      val m = AvroSerdes.deserialize(src, f.exeSchema.get)
+      val n = f.avroToCatalyst.map(_(m))
+      n.get
+    } else {
+      // Fall back to atomic type
+      f.dt match {
+        case BooleanType => src(offset) != 0
+        case ByteType => src(offset)
+        case ShortType => Bytes.toShort(src, offset)
+        case IntegerType => Bytes.toInt(src, offset)
+        case LongType => Bytes.toLong(src, offset)
+        case FloatType => Bytes.toFloat(src, offset)
+        case DoubleType => Bytes.toDouble(src, offset)
+        case DateType => new Date(Bytes.toLong(src, offset))
+        case TimestampType => new Timestamp(Bytes.toLong(src, offset))
+        case StringType => Bytes.toString(src, offset, length)
+        case BinaryType =>
+          val newArray = new Array[Byte](length)
+          System.arraycopy(src, offset, newArray, 0, length)
+          newArray
+        case _: DecimalType => Bytes.toBigDecimal(src, offset, length)
+        case _ => throw new Exception(s"unsupported data type ${f.dt}")
+      }
+    }
+  }
+
+  // convert input to data type
+  def toBytes(input: Any, field: Field): Array[Byte] = {
+    if (field.schema.isDefined) {
+      // Here we assume the top level type is structType
+      val record = field.catalystToAvro(input)
+      AvroSerdes.serialize(record, field.schema.get)
+    } else {
+      field.dt match {
+        case BooleanType => Bytes.toBytes(input.asInstanceOf[Boolean])
+        case ByteType => Array(input.asInstanceOf[Number].byteValue)
+        case ShortType => Bytes.toBytes(input.asInstanceOf[Number].shortValue)
+        case IntegerType => Bytes.toBytes(input.asInstanceOf[Number].intValue)
+        case LongType => Bytes.toBytes(input.asInstanceOf[Number].longValue)
+        case FloatType => Bytes.toBytes(input.asInstanceOf[Number].floatValue)
+        case DoubleType => 
Bytes.toBytes(input.asInstanceOf[Number].doubleValue)
+        case DateType | TimestampType => 
Bytes.toBytes(input.asInstanceOf[java.util.Date].getTime)
+        case StringType => Bytes.toBytes(input.toString)
+        case BinaryType => input.asInstanceOf[Array[Byte]]
+        case _: DecimalType => 
Bytes.toBytes(input.asInstanceOf[java.math.BigDecimal])
+        case _ => throw new Exception(s"unsupported data type ${field.dt}")
+      }
+    }
+  }
+
+  // increment Byte array's value by 1
+  def incrementByteArray(array: Array[Byte]): Array[Byte] = {
+    if (array.length == 0) {
+      return null
+    }
+    var index = -1
+    var a = array.length - 1
+
+    while (a >= 0) {
+      if (array(a) != (-1).toByte) {
+        index = a
+        a = -1
+      }
+      a = a - 1
+    }
+
+    if (index < 0) {
+      return null
+    }
+    val returnArray = new Array[Byte](array.length)
+
+    for (a <- 0 until index) {
+      returnArray(a) = array(a)
+    }
+    returnArray(index) = (array(index) + 1).toByte
+    for (a <- index + 1 until array.length) {
+      returnArray(a) = 0.toByte
+    }
+
+    returnArray
+  }
+}

Reply via email to