diff --git a/src/common/pom.xml b/src/common/pom.xml
index 8c65730..4b91d63 100644
--- a/src/common/pom.xml
+++ b/src/common/pom.xml
@@ -12,6 +12,8 @@
UTF-8
official
1.8
+ 2.1.0
+ 1.8.0
@@ -28,7 +30,7 @@
org.jetbrains.kotlin
kotlin-maven-plugin
- 2.1.0
+ ${kotlin.version}
compile
@@ -45,6 +47,18 @@
+
+
+ kotlinx-serialization
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-serialization
+ ${kotlin.version}
+
+
maven-surefire-plugin
@@ -75,18 +89,23 @@
org.jetbrains.kotlin
kotlin-test-junit5
- 2.1.0
+ ${kotlin.version}
test
org.jetbrains.kotlin
kotlin-stdlib
- 2.1.0
+ ${kotlin.version}
org.jetbrains.kotlin
kotlin-reflect
- 2.0.20
+ ${kotlin.version}
+
+
+ org.jetbrains.kotlinx
+ kotlinx-serialization-json
+ ${serialization.version}
diff --git a/src/common/src/main/kotlin/AutoId.kt b/src/common/src/main/kotlin/AutoId.kt
index cf34802..f152a8d 100644
--- a/src/common/src/main/kotlin/AutoId.kt
+++ b/src/common/src/main/kotlin/AutoId.kt
@@ -54,19 +54,13 @@ enum class AutoId {
if (id == null) throw IllegalArgumentException("$idProp not found in document")
if (strategy == NUMBER) {
- if (id.returnType == Byte::class.createType()) {
- return id.call(document) == 0.toByte()
+ return when (id.returnType) {
+ Byte::class.createType() -> id.call(document) == 0.toByte()
+ Short::class.createType() -> id.call(document) == 0.toShort()
+ Int::class.createType() -> id.call(document) == 0
+ Long::class.createType() -> id.call(document) == 0.toLong()
+ else -> throw IllegalArgumentException("$idProp was not a number; cannot auto-generate number ID")
}
- if (id.returnType == Short::class.createType()) {
- return id.call(document) == 0.toShort()
- }
- if (id.returnType == Int::class.createType()) {
- return id.call(document) == 0
- }
- if (id.returnType == Long::class.createType()) {
- return id.call(document) == 0.toLong()
- }
- throw IllegalArgumentException("$idProp was not a number; cannot auto-generate number ID")
}
if (id.returnType == String::class.createType()) {
diff --git a/src/common/src/main/kotlin/Configuration.kt b/src/common/src/main/kotlin/Configuration.kt
index ca82639..34c5e93 100644
--- a/src/common/src/main/kotlin/Configuration.kt
+++ b/src/common/src/main/kotlin/Configuration.kt
@@ -1,8 +1,21 @@
package solutions.bitbadger.documents.common
+import kotlinx.serialization.json.Json
+import java.sql.Connection
+import java.sql.DriverManager
+
object Configuration {
- // TODO: var jsonOpts = Json { some cool options }
+ /**
+ * JSON serializer; replace to configure with non-default options
+ *
+ * The default sets `encodeDefaults` to `true` and `explicitNulls` to `false`; see
+ * https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md for all configuration options
+ */
+ var json = Json {
+ encodeDefaults = true
+ explicitNulls = false
+ }
/** The field in which a document's ID is stored */
var idField = "id"
@@ -12,4 +25,40 @@ object Configuration {
/** The length of automatic random hex character string */
var idStringLength = 16
+
+ /** The connection string for the JDBC connection */
+ var connectionString: String? = null
+
+ /**
+ * Retrieve a new connection to the configured database
+ *
+ * @return A new connection to the configured database
+ * @throws IllegalArgumentException If the connection string is not set before calling this
+ */
+ fun dbConn(): Connection {
+ if (connectionString == null) {
+ throw IllegalArgumentException("Please provide a connection string before attempting data access")
+ }
+ return DriverManager.getConnection(connectionString)
+ }
+
+ private var dialectValue: Dialect? = null
+
+ /** The dialect in use */
+ val dialect: Dialect
+ get() {
+ if (dialectValue == null) {
+ if (connectionString == null) {
+ throw IllegalArgumentException("Please provide a connection string before attempting data access")
+ }
+ val it = connectionString!!
+ dialectValue = when {
+ it.contains("sqlite") -> Dialect.SQLITE
+ it.contains("postgresql") -> Dialect.POSTGRESQL
+ else -> throw IllegalArgumentException("Cannot determine dialect from [$it]")
+ }
+ }
+
+ return dialectValue!!
+ }
}
diff --git a/src/common/src/main/kotlin/ConnectionExtensions.kt b/src/common/src/main/kotlin/ConnectionExtensions.kt
new file mode 100644
index 0000000..e18a7f4
--- /dev/null
+++ b/src/common/src/main/kotlin/ConnectionExtensions.kt
@@ -0,0 +1,40 @@
+package solutions.bitbadger.documents.common
+
+import java.sql.Connection
+import java.sql.ResultSet
+
+/**
+ * Execute a query that returns a list of results
+ *
+ * @param query The query to retrieve the results
+ * @param parameters Parameters to use for the query
+ * @param mapFunc The mapping function between the document and the domain item
+ * @return A list of results for the given query
+ */
+inline fun Connection.customList(query: String, parameters: Collection>,
+ mapFunc: (ResultSet) -> TDoc): List =
+ Parameters.apply(this, query, parameters).use { stmt ->
+ Results.toCustomList(stmt, mapFunc)
+ }
+
+/**
+ * Execute a query that returns one or no results
+ *
+ * @param query The query to retrieve the results
+ * @param parameters Parameters to use for the query
+ * @param mapFunc The mapping function between the document and the domain item
+ * @return The document if one matches the query, `null` otherwise
+ */
+inline fun Connection.customSingle(query: String, parameters: Collection>,
+ mapFunc: (ResultSet) -> TDoc): TDoc? =
+ this.customList("$query LIMIT 1", parameters, mapFunc).singleOrNull()
+
+/**
+ * Execute a query that returns no results
+ *
+ * @param query The query to retrieve the results
+ * @param parameters Parameters to use for the query
+ */
+fun Connection.customNonQuery(query: String, parameters: Collection>) {
+ Parameters.apply(this, query, parameters).use { it.executeUpdate() }
+}
diff --git a/src/common/src/main/kotlin/DocumentException.kt b/src/common/src/main/kotlin/DocumentException.kt
new file mode 100644
index 0000000..6901d2b
--- /dev/null
+++ b/src/common/src/main/kotlin/DocumentException.kt
@@ -0,0 +1,9 @@
+package solutions.bitbadger.documents.common
+
+/**
+ * An exception caused by invalid operations in the document library
+ *
+ * @param message The message for the exception
+ * @param cause The underlying exception (optional)
+ */
+class DocumentException(message: String, cause: Throwable? = null) : Exception(message, cause)
diff --git a/src/common/src/main/kotlin/Parameter.kt b/src/common/src/main/kotlin/Parameter.kt
new file mode 100644
index 0000000..eca25b7
--- /dev/null
+++ b/src/common/src/main/kotlin/Parameter.kt
@@ -0,0 +1,15 @@
+package solutions.bitbadger.documents.common
+
+/**
+ * A parameter to use for a query
+ *
+ * @property name The name of the parameter (prefixed with a colon)
+ * @property type The type of this parameter
+ * @property value The value of the parameter
+ */
+class Parameter(val name: String, val type: ParameterType, val value: T) {
+ init {
+ if (!name.startsWith(':') && !name.startsWith('@'))
+ throw DocumentException("Name must start with : or @ ($name)")
+ }
+}
diff --git a/src/common/src/main/kotlin/ParameterType.kt b/src/common/src/main/kotlin/ParameterType.kt
new file mode 100644
index 0000000..53d6e1b
--- /dev/null
+++ b/src/common/src/main/kotlin/ParameterType.kt
@@ -0,0 +1,13 @@
+package solutions.bitbadger.documents.common
+
+/**
+ * The types of parameters supported by the document library
+ */
+enum class ParameterType {
+ /** The parameter value is some sort of number (`Byte`, `Short`, `Int`, or `Long`) */
+ NUMBER,
+ /** The parameter value is a string */
+ STRING,
+ /** The parameter should be JSON-encoded */
+ JSON,
+}
diff --git a/src/common/src/main/kotlin/Parameters.kt b/src/common/src/main/kotlin/Parameters.kt
new file mode 100644
index 0000000..4675abc
--- /dev/null
+++ b/src/common/src/main/kotlin/Parameters.kt
@@ -0,0 +1,83 @@
+package solutions.bitbadger.documents.common
+
+import java.sql.Connection
+import java.sql.PreparedStatement
+import java.sql.SQLException
+import java.sql.Types
+
+/**
+ * Functions to assist with the creation and implementation of parameters for SQL queries
+ *
+ * @author Daniel J. Summers
+ */
+object Parameters {
+
+ /**
+ * Replace the parameter names in the query with question marks
+ *
+ * @param query The query with named placeholders
+ * @param parameters The parameters for the query
+ * @return The query, with name parameters changed to `?`s
+ */
+ fun replaceNamesInQuery(query: String, parameters: Collection>) =
+ parameters.sortedByDescending { it.name.length }.fold(query) { acc, param -> acc.replace(param.name, "?") }
+
+ /**
+ * Apply the given parameters to the given query, returning a prepared statement
+ *
+ * @param conn The active JDBC connection
+ * @param query The query
+ * @param parameters The parameters for the query
+ * @return A `PreparedStatement` with the parameter names replaced with `?` and parameter values bound
+ * @throws DocumentException If parameter names are invalid or number value types are invalid
+ */
+ fun apply(conn: Connection, query: String, parameters: Collection>): PreparedStatement {
+ if (parameters.isEmpty()) return try {
+ conn.prepareStatement(query)
+ } catch (ex: SQLException) {
+ throw DocumentException("Error preparing no-parameter query: ${ex.message}", ex)
+ }
+
+ val replacements = mutableListOf>>()
+ parameters.sortedByDescending { it.name.length }.forEach {
+ var startPos = query.indexOf(it.name)
+ while (startPos > -1) {
+ replacements.add(Pair(startPos, it))
+ startPos = query.indexOf(it.name, startPos + it.name.length + 1)
+ }
+ }
+
+ return try {
+ replaceNamesInQuery(query, parameters)
+ .let { conn.prepareStatement(it) }
+ .also { stmt ->
+ replacements.sortedBy { it.first }.map { it.second }.forEachIndexed { index, param ->
+ val idx = index + 1
+ when (param.type) {
+ ParameterType.NUMBER -> {
+ when (param.value) {
+ null -> stmt.setNull(idx, Types.NULL)
+ is Byte -> stmt.setByte(idx, param.value)
+ is Short -> stmt.setShort(idx, param.value)
+ is Int -> stmt.setInt(idx, param.value)
+ is Long -> stmt.setLong(idx, param.value)
+ else -> throw DocumentException(
+ "Number parameter must be Byte, Short, Int, or Long (${param.value::class.simpleName})")
+ }
+ }
+ ParameterType.STRING -> {
+ when (param.value) {
+ null -> stmt.setNull(idx, Types.NULL)
+ is String -> stmt.setString(idx, param.value)
+ else -> stmt.setString(idx, param.value.toString())
+ }
+ }
+ ParameterType.JSON -> stmt.setString(idx, Configuration.json.encodeToString(param.value))
+ }
+ }
+ }
+ } catch (ex: SQLException) {
+ throw DocumentException("Error creating query / binding parameters: ${ex.message}", ex)
+ }
+ }
+}
diff --git a/src/common/src/main/kotlin/Query.kt b/src/common/src/main/kotlin/Query.kt
index 31e6a67..4a0dbad 100644
--- a/src/common/src/main/kotlin/Query.kt
+++ b/src/common/src/main/kotlin/Query.kt
@@ -146,22 +146,21 @@ object Query {
} else {
Pair, String?>(it, null)
}
- val path =
- if (field.name.startsWith("n:")) {
- val fld = Field.named(field.name.substring(2))
+ val path = when {
+ field.name.startsWith("n:") -> Field.named(field.name.substring(2)).let { fld ->
when (dialect) {
Dialect.POSTGRESQL -> "(${fld.path(dialect)})::numeric"
Dialect.SQLITE -> fld.path(dialect)
}
- } else if (field.name.startsWith("i:")) {
- val p = Field.named(field.name.substring(2)).path(dialect)
+ }
+ field.name.startsWith("i:") -> Field.named(field.name.substring(2)).path(dialect).let { p ->
when (dialect) {
Dialect.POSTGRESQL -> "LOWER($p)"
Dialect.SQLITE -> "$p COLLATE NOCASE"
}
- } else {
- field.path(dialect)
}
+ else -> field.path(dialect)
+ }
"$path${direction ?: ""}"
}
return " ORDER BY $orderFields"
diff --git a/src/common/src/main/kotlin/Results.kt b/src/common/src/main/kotlin/Results.kt
new file mode 100644
index 0000000..777a228
--- /dev/null
+++ b/src/common/src/main/kotlin/Results.kt
@@ -0,0 +1,75 @@
+package solutions.bitbadger.documents.common
+
+import java.sql.PreparedStatement
+import java.sql.ResultSet
+import java.sql.SQLException
+
+/**
+ * Helper functions for handling results
+ */
+object Results {
+
+ /**
+ * Create a domain item from a document, specifying the field in which the document is found
+ *
+ * @param field The field name containing the JSON document
+ * @param rs A `ResultSet` set to the row with the document to be constructed
+ * @return The constructed domain item
+ */
+ inline fun fromDocument(field: String, rs: ResultSet): TDoc =
+ Configuration.json.decodeFromString(rs.getString(field))
+
+ /**
+ * Create a domain item from a document
+ *
+ * @param rs A `ResultSet` set to the row with the document to be constructed<
+ * @return The constructed domain item
+ */
+ inline fun fromData(rs: ResultSet): TDoc =
+ fromDocument("data", rs)
+
+ /**
+ * Create a list of items for the results of the given command, using the specified mapping function
+ *
+ * @param stmt The prepared statement to execute
+ * @param mapFunc The mapping function from data reader to domain class instance
+ * @return A list of items from the query's result
+ * @throws DocumentException If there is a problem executing the query
+ */
+ inline fun toCustomList(stmt: PreparedStatement, mapFunc: (ResultSet) -> TDoc): List =
+ try {
+ stmt.executeQuery().use {
+ val results = mutableListOf()
+ while (it.next()) {
+ results.add(mapFunc(it))
+ }
+ results.toList()
+ }
+ } catch (ex: SQLException) {
+ throw DocumentException("Error retrieving documents from query: ${ex.message}", ex)
+ }
+
+ /**
+ * Extract a count from the first column
+ *
+ * @param rs A `ResultSet` set to the row with the count to retrieve
+ * @return The count from the row
+ */
+ fun toCount(rs: ResultSet): Long =
+ when (Configuration.dialect) {
+ Dialect.POSTGRESQL -> rs.getInt("it").toLong()
+ Dialect.SQLITE -> rs.getLong("it")
+ }
+
+ /**
+ * Extract a true/false value from the first column
+ *
+ * @param rs A `ResultSet` set to the row with the true/false value to retrieve
+ * @return The true/false value from the row
+ */
+ fun toExists(rs: ResultSet): Boolean =
+ when (Configuration.dialect) {
+ Dialect.POSTGRESQL -> rs.getBoolean("it")
+ Dialect.SQLITE -> toCount(rs) > 0L
+ }
+}
diff --git a/src/common/src/test/kotlin/ParameterTest.kt b/src/common/src/test/kotlin/ParameterTest.kt
new file mode 100644
index 0000000..7cc17e3
--- /dev/null
+++ b/src/common/src/test/kotlin/ParameterTest.kt
@@ -0,0 +1,34 @@
+package solutions.bitbadger.documents.common
+
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.assertThrows
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class ParameterTest {
+
+ @Test
+ @DisplayName("Construction with colon-prefixed name")
+ fun ctorWithColon() {
+ val p = Parameter(":test", ParameterType.STRING, "ABC")
+ assertEquals(":test", p.name, "Parameter name was incorrect")
+ assertEquals(ParameterType.STRING, p.type, "Parameter type was incorrect")
+ assertEquals("ABC", p.value, "Parameter value was incorrect")
+ }
+
+ @Test
+ @DisplayName("Construction with at-sign-prefixed name")
+ fun ctorWithAtSign() {
+ val p = Parameter("@yo", ParameterType.NUMBER, null)
+ assertEquals("@yo", p.name, "Parameter name was incorrect")
+ assertEquals(ParameterType.NUMBER, p.type, "Parameter type was incorrect")
+ assertNull(p.value, "Parameter value was incorrect")
+ }
+
+ @Test
+ @DisplayName("Construction fails with incorrect prefix")
+ fun ctorFailsForPrefix() {
+ assertThrows { Parameter("it", ParameterType.JSON, "") }
+ }
+}
diff --git a/src/common/src/test/kotlin/ParametersTest.kt b/src/common/src/test/kotlin/ParametersTest.kt
new file mode 100644
index 0000000..0394a12
--- /dev/null
+++ b/src/common/src/test/kotlin/ParametersTest.kt
@@ -0,0 +1,18 @@
+package solutions.bitbadger.documents.common
+
+import org.junit.jupiter.api.DisplayName
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ParametersTest {
+
+ @Test
+ @DisplayName("replaceNamesInQuery replaces successfully")
+ fun replaceNamesInQuery() {
+ val parameters = listOf(Parameter(":data", ParameterType.JSON, "{}"),
+ Parameter(":data_ext", ParameterType.STRING, ""))
+ val query = "SELECT data, data_ext FROM tbl WHERE data = :data AND data_ext = :data_ext AND more_data = :data"
+ assertEquals("SELECT data, data_ext FROM tbl WHERE data = ? AND data_ext = ? AND more_data = ?",
+ Parameters.replaceNamesInQuery(query, parameters), "Parameters not replaced correctly")
+ }
+}
diff --git a/src/sqlite/src/main/kotlin/Query.kt b/src/sqlite/src/main/kotlin/Query.kt
index 18863df..df75709 100644
--- a/src/sqlite/src/main/kotlin/Query.kt
+++ b/src/sqlite/src/main/kotlin/Query.kt
@@ -1,7 +1,7 @@
package solutions.bitbadger.documents.sqlite
import solutions.bitbadger.documents.common.*
-import solutions.bitbadger.documents.common.Configuration
+import solutions.bitbadger.documents.common.Configuration as BaseConfig;
import solutions.bitbadger.documents.common.Query
/**
@@ -30,13 +30,16 @@ object Query {
}
Op.IN -> {
val p = name.derive(it.parameterName)
- val values = comp.value as List<*>
+ val values = comp.value as Collection<*>
val paramNames = values.indices.joinToString(", ") { idx -> "${p}_$idx" }
"${it.path(Dialect.SQLITE, FieldFormat.SQL)} ${comp.op.sql} ($paramNames)"
}
Op.IN_ARRAY -> {
val p = name.derive(it.parameterName)
- val (table, values) = comp.value as Pair>
+ @Suppress("UNCHECKED_CAST")
+ val tableAndValues = comp.value as? Pair>
+ ?: throw IllegalArgumentException("InArray field invalid")
+ val (table, values) = tableAndValues
val paramNames = values.indices.joinToString(", ") { idx -> "${p}_$idx" }
"EXISTS (SELECT 1 FROM json_each($table.data, '$.${it.name}') WHERE value IN ($paramNames)"
}
@@ -54,7 +57,8 @@ object Query {
* @return A `WHERE` clause fragment identifying a document by its ID
*/
fun whereById(docId: TKey): String =
- whereByFields(FieldMatch.ANY, listOf(Field.equal(Configuration.idField, docId).withParameterName(":id")))
+ whereByFields(FieldMatch.ANY,
+ listOf(Field.equal(BaseConfig.idField, docId).withParameterName(":id")))
/**
* Create an `UPDATE` statement to patch documents
diff --git a/src/sqlite/src/test/kotlin/QueryTest.kt b/src/sqlite/src/test/kotlin/QueryTest.kt
new file mode 100644
index 0000000..627a6f7
--- /dev/null
+++ b/src/sqlite/src/test/kotlin/QueryTest.kt
@@ -0,0 +1,99 @@
+package solutions.bitbadger.documents.sqlite
+
+import org.junit.jupiter.api.DisplayName
+import solutions.bitbadger.documents.common.Field
+import solutions.bitbadger.documents.common.FieldMatch
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class QueryTest {
+
+ @Test
+ @DisplayName("whereByFields generates for a single field with logical operator")
+ fun whereByFieldSingleLogical() {
+ assertEquals("data->>'theField' > :test",
+ Query.whereByFields(FieldMatch.ANY, listOf(Field.greater("theField", 0).withParameterName(":test"))),
+ "WHERE clause not correct")
+ }
+
+ @Test
+ @DisplayName("whereByFields generates for a single field with existence operator")
+ fun whereByFieldSingleExistence() {
+ assertEquals("data->>'thatField' IS NULL",
+ Query.whereByFields(FieldMatch.ANY, listOf(Field.notExists("thatField"))), "WHERE clause not correct")
+ }
+
+ @Test
+ @DisplayName("whereByFields generates for a single field with between operator")
+ fun whereByFieldSingleBetween() {
+ assertEquals("data->>'aField' BETWEEN :rangemin AND :rangemax",
+ Query.whereByFields(FieldMatch.ALL, listOf(Field.between("aField", 50, 99).withParameterName(":range"))),
+ "WHERE clause not correct")
+ }
+
+ @Test
+ @DisplayName("whereByFields generates for all multiple fields with logical operator")
+ fun whereByFieldAllMultipleLogical() {
+ assertEquals("data->>'theFirst' = :field0 AND data->>'numberTwo' = :field1",
+ Query.whereByFields(FieldMatch.ALL, listOf(Field.equal("theFirst", "1"), Field.equal("numberTwo", "2"))),
+ "WHERE clause not correct")
+ }
+
+ @Test
+ @DisplayName("whereByFields generates for any multiple fields with existence operator")
+ fun whereByFieldAnyMultipleExistence() {
+ assertEquals("data->>'thatField' IS NULL OR data->>'thisField' >= :field0",
+ Query.whereByFields(FieldMatch.ANY,
+ listOf(Field.notExists("thatField"), Field.greaterOrEqual("thisField", 18))),
+ "WHERE clause not correct")
+ }
+
+ @Test
+ @DisplayName("whereByFields generates for an In comparison")
+ fun whereByFieldIn() {
+ assertEquals("data->>'this' IN (:field0_0, :field0_1, :field0_2)",
+ Query.whereByFields(FieldMatch.ALL, listOf(Field.any("this", listOf("a", "b", "c")))),
+ "WHERE clause not correct")
+ }
+
+ @Test
+ @DisplayName("whereByFields generates for an InArray comparison")
+ fun whereByFieldInArray() {
+ assertEquals("EXISTS (SELECT 1 FROM json_each(the_table.data, '$.this') WHERE value IN (:field0_0, :field0_1)",
+ Query.whereByFields(FieldMatch.ALL, listOf(Field.inArray("this", "the_table", listOf("a", "b")))),
+ "WHERE clause not correct")
+ }
+
+ @Test
+ @DisplayName("whereById generates correctly")
+ fun whereById() {
+ assertEquals("data->>'id' = :id", Query.whereById("abc"), "WHERE clause not correct")
+ }
+
+ @Test
+ @DisplayName("patch generates the correct query")
+ fun patch() {
+ assertEquals("UPDATE my_table SET data = json_patch(data, json(:data))", Query.patch("my_table"),
+ "Query not correct")
+ }
+
+ @Test
+ @DisplayName("byId generates the correct query")
+ fun byId() {
+ assertEquals("test WHERE data->>'id' = :id", Query.byId("test", "14"), "By-ID query not correct")
+ }
+
+ @Test
+ @DisplayName("byFields generates the correct query")
+ fun byFields() {
+ assertEquals("unit WHERE data->>'That' > :field0",
+ Query.byFields("unit", FieldMatch.ANY, listOf(Field.greater("That", 14))), "By-fields query not correct")
+ }
+
+ @Test
+ @DisplayName("Definition.ensureTable generates the correct query")
+ fun ensureTable() {
+ assertEquals("CREATE TABLE IF NOT EXISTS tbl (data TEXT NOT NULL)", Query.Definition.ensureTable("tbl"),
+ "CREATE TABLE statement not correct")
+ }
+}