diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/insertions/CassandraScriptRunnerTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/insertions/CassandraScriptRunnerTest.java index e471771b99..8abfdd7561 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/insertions/CassandraScriptRunnerTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/insertions/CassandraScriptRunnerTest.java @@ -46,7 +46,7 @@ public static void initClass() { connection.execute("CREATE KEYSPACE IF NOT EXISTS " + KEYSPACE + " WITH replication = {'class':'SimpleStrategy','replication_factor':1}"); connection.execute("CREATE TABLE IF NOT EXISTS " + KEYSPACE + "." + TABLE + - " (id int PRIMARY KEY, name text)"); + " (id int PRIMARY KEY, name text, elapsed duration)"); } @AfterAll @@ -77,6 +77,26 @@ public void testInsert() { assertTrue(connection.execute("SELECT * FROM " + KEYSPACE + "." + TABLE).iterator().hasNext()); } + /** + * A duration is written as a bare literal, ie not enclosed in quotes the way a text is, and it + * carries at most one leading sign, applying to the whole value. This is what + * CassandraLiteralRenderer in the core module relies on when rendering the value of a + * CqlDurationGene, so it is checked here against a real Cassandra. + */ + @Test + public void testInsertDuration() { + List insertions = CassandraDsl.cassandra() + .insertInto(KEYSPACE, TABLE).d("id", "1").d("elapsed", "1mo2d3ns") + .and().insertInto(KEYSPACE, TABLE).d("id", "2").d("elapsed", "-1mo2d3ns") + .dtos(); + + CassandraInsertionResultsDto resultsDto = CassandraScriptRunner.executeInsert(connection, insertions); + + assertTrue(resultsDto.executionResults.get(0)); + assertTrue(resultsDto.executionResults.get(1)); + assertEquals(2, connection.execute("SELECT * FROM " + KEYSPACE + "." + TABLE).all().size()); + } + @Test public void testInsertionFailureDoesNotStopFollowingInsertions() { diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumn.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumn.kt new file mode 100644 index 0000000000..8f34534ade --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumn.kt @@ -0,0 +1,25 @@ +package org.evomaster.core.database.cassandra + +/** + * A single column of a Cassandra table, as recovered from the schema description string carried by + * a failed CQL query reported by the SUT driver. + */ +data class CassandraColumn( + + val name: String, + + /** + * The CQL type of the column, as named in the CQL schema, eg "text", "int", "map". + */ + val cqlType: String, + + /** + * Whether this column is part of the table's partition key. + */ + val isPartitionKey: Boolean = false, + + /** + * Whether this column is one of the table's clustering columns. + */ + val isClusteringColumn: Boolean = false +) diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilder.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilder.kt new file mode 100644 index 0000000000..3f9c897b25 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilder.kt @@ -0,0 +1,74 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.Gene +import org.evomaster.core.search.gene.UUIDGene +import org.evomaster.core.search.gene.cassandra.CqlDurationGene +import org.evomaster.core.search.gene.datetime.DateGene +import org.evomaster.core.search.gene.datetime.DateTimeGene +import org.evomaster.core.search.gene.datetime.TimeGene +import org.evomaster.core.search.gene.numeric.* +import org.evomaster.core.search.gene.string.StringGene + +/** + * Builds the gene used to generate the value of a Cassandra column, based on its CQL type. + * + * Two different reasons keep a CQL type out of the ones handled here: + * - the value of a column of that type cannot be generated at all, ie a counter, which is only + * writable with an UPDATE, and a timeuuid, which requires a version 1 UUID, whereas [UUIDGene] + * generates a random one; + * - no gene generating a value of that type has been written yet, ie blob, inet, the collections + * and the user defined types. + */ +object CassandraColumnGeneBuilder { + + /** + * How the gene generating the value of a column is built, for each of the CQL types handled + * here, keyed by the normalized name of the type. Being the single place where such types are + * enumerated, it is also what [isSupported] answers from, so that the two cannot disagree. + */ + private val GENE_BUILDERS: Map Gene> = mapOf( + "ascii" to { name -> StringGene(name) }, + "text" to { name -> StringGene(name) }, + "varchar" to { name -> StringGene(name) }, + "tinyint" to { name -> IntegerGene(name, min = Byte.MIN_VALUE.toInt(), max = Byte.MAX_VALUE.toInt()) }, + "smallint" to { name -> IntegerGene(name, min = Short.MIN_VALUE.toInt(), max = Short.MAX_VALUE.toInt()) }, + "int" to { name -> IntegerGene(name) }, + "bigint" to { name -> LongGene(name) }, + "varint" to { name -> BigIntegerGene(name) }, + "decimal" to { name -> BigDecimalGene(name) }, + "float" to { name -> FloatGene(name) }, + "double" to { name -> DoubleGene(name) }, + "boolean" to { name -> BooleanGene(name) }, + "uuid" to { name -> UUIDGene(name) }, + /* + Only valid values are generated, as these genes are used to set up the state of the + database, and Cassandra would just reject an insertion carrying an invalid one. + */ + "timestamp" to { name -> DateTimeGene(name, onlyValid = true) }, + "date" to { name -> DateGene(name, onlyValidDates = true) }, + "time" to { name -> TimeGene(name, onlyValidTimes = true) }, + "duration" to { name -> CqlDurationGene(name) } + ) + + /** + * @return whether a gene can be built for [column], ie whether its CQL type is one of the + * scalar types handled here + */ + fun isSupported(column: CassandraColumn) = normalize(column.cqlType) in GENE_BUILDERS + + /** + * @throws IllegalArgumentException if the CQL type of [column] is not handled, as verifiable + * beforehand with [isSupported] + */ + fun buildGene(column: CassandraColumn): Gene { + + val builder = GENE_BUILDERS[normalize(column.cqlType)] + ?: throw IllegalArgumentException("Cannot handle the CQL type of column $column") + + return builder(column.name) + } + + private fun normalize(cqlType: String) = cqlType.trim().lowercase() + +} diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraDbAction.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraDbAction.kt new file mode 100644 index 0000000000..eafd636b69 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraDbAction.kt @@ -0,0 +1,56 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.core.search.action.Action +import org.evomaster.core.search.action.EnvironmentAction +import org.evomaster.core.search.gene.Gene + +/** + * An action inserting a single row into a Cassandra table, used to set up the state of the database + * before the main actions of a test are executed. + */ +class CassandraDbAction( + /** + * The keyspace containing the table to insert the row into + */ + val keyspace: String, + /** + * The table to insert the row into + */ + val table: String, + /** + * The columns the row is composed of, ie the ones a value is generated for. + * There is exactly one gene per column, in the same order. + */ + val columns: List, + /** + * The genes generating the value of each of the [columns], in the same order. + * Only meant to be given when copying an existing action, so that its genes are carried over + * instead of being built anew: when not given, one gene is built per column. + */ + computedGenes: List? = null +) : EnvironmentAction(listOf()) { + + private val genes: List = (computedGenes ?: computeGenes()).also { addChildren(it) } + + init { + if (genes.size != columns.size) { + throw IllegalArgumentException("Mismatch between the ${columns.size} columns and the ${genes.size} genes") + } + } + + private fun computeGenes(): List { + return columns.map { CassandraColumnGeneBuilder.buildGene(it) } + } + + override fun getName(): String { + return "CASSANDRA_Insert_${keyspace}_${table}" + } + + override fun seeTopGenes(): List { + return genes + } + + override fun copyContent(): Action { + return CassandraDbAction(keyspace, table, columns, genes.map(Gene::copy)) + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraDbActionResult.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraDbActionResult.kt new file mode 100644 index 0000000000..2279c2381e --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraDbActionResult.kt @@ -0,0 +1,36 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.core.search.action.Action +import org.evomaster.core.search.action.ActionResult + +/** + * Cassandra insert action execution result + */ +class CassandraDbActionResult : ActionResult { + + constructor(sourceLocalId: String, stopping: Boolean = false) : super(sourceLocalId, stopping) + constructor(other: CassandraDbActionResult) : super(other) + + companion object { + const val INSERT_CASSANDRA_EXECUTE_SUCCESSFULLY = "INSERT_CASSANDRA_EXECUTE_SUCCESSFULLY" + } + + override fun copy(): CassandraDbActionResult { + return CassandraDbActionResult(this) + } + + /** + * @param success specifies whether the INSERT CASSANDRA executed successfully + */ + fun setInsertExecutionResult(success: Boolean) = + addResultValue(INSERT_CASSANDRA_EXECUTE_SUCCESSFULLY, success.toString()) + + /** + * @return whether the Cassandra action executed successfully + */ + fun getInsertExecutionResult() = getResultValue(INSERT_CASSANDRA_EXECUTE_SUCCESSFULLY)?.toBoolean() ?: false + + override fun matchedType(action: Action): Boolean { + return action is CassandraDbAction + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraDbActionTransformer.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraDbActionTransformer.kt new file mode 100644 index 0000000000..303955a75e --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraDbActionTransformer.kt @@ -0,0 +1,39 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.client.java.controller.api.dto.database.operations.CassandraDatabaseCommandDto +import org.evomaster.client.java.controller.api.dto.database.operations.CassandraInsertionDto +import org.evomaster.client.java.controller.api.dto.database.operations.CassandraInsertionEntryDto + +/** + * Transforms the Cassandra insert actions of an individual into the commands to be executed on the + * SUT side. + */ +object CassandraDbActionTransformer { + + fun transform(actions: List): CassandraDatabaseCommandDto { + + val insertionDtos = mutableListOf() + + for (action in actions) { + + val insertionDto = CassandraInsertionDto().apply { + keyspaceName = action.keyspace + tableName = action.table + } + + action.seeTopGenes() + .filter { it.isPrintable() } + .forEach { gene -> + val entry = CassandraInsertionEntryDto().apply { + columnName = gene.name + printableValue = CassandraLiteralRenderer.toCqlLiteral(gene) + } + insertionDto.data.add(entry) + } + + insertionDtos.add(insertionDto) + } + + return CassandraDatabaseCommandDto().apply { this.insertions = insertionDtos } + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilder.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilder.kt new file mode 100644 index 0000000000..70f63cb849 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilder.kt @@ -0,0 +1,83 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.core.logging.LoggingUtil +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Builds the action inserting a row into a Cassandra table, based on the description of the + * columns of that table reported by the SUT driver. + */ +class CassandraInsertBuilder { + + companion object { + private val log: Logger = LoggerFactory.getLogger(CassandraInsertBuilder::class.java) + } + + /** + * @param tableSchema the description of the columns of a table, as reported by the SUT driver + * @return whether an insertion that could be executed can be built for such a table, ie whether + * a value can be generated for at least one of its columns and for all of the ones composing + * its primary key + */ + fun canBuildInsertionFor(tableSchema: String): Boolean { + + val (supported, unsupported) = partitionBySupport(CassandraTableSchemaParser.parse(tableSchema)) + + return supported.isNotEmpty() && unsupported.none { isPartOfPrimaryKey(it) } + } + + /** + * The columns whose CQL type is not handled are left out of the insertion, as no value can be + * generated for them. The resulting insertion is still worth executing, since the remaining + * columns might be all that is needed, and a rejected insertion is already recorded as a failed + * one instead of stopping the search. + * + * That argument does not hold when no value can be generated for any column, nor when one of + * the skipped columns is part of the primary key, as Cassandra requires a full primary key in + * an INSERT: in both cases the insertion could only be rejected, so none is built. + * + * Note that the genes of the returned action are not initialized yet, which is left to the + * caller, as it is done for the other types of database action. + * + * @throws IllegalArgumentException if no insertion that could be executed can be built for the + * table, as verifiable beforehand with [canBuildInsertionFor] + */ + fun createCassandraInsertionAction(keyspace: String, table: String, tableSchema: String): CassandraDbAction { + + val (supported, unsupported) = partitionBySupport(CassandraTableSchemaParser.parse(tableSchema)) + + val qualifiedTableName = "$keyspace.$table" + + if (supported.isEmpty()) { + throw IllegalArgumentException("No value can be generated for any column of" + + " $qualifiedTableName: ${describe(unsupported)}") + } + + val unsupportedKeyColumns = unsupported.filter { isPartOfPrimaryKey(it) } + if (unsupportedKeyColumns.isNotEmpty()) { + throw IllegalArgumentException("No value can be generated for some of the columns composing" + + " the primary key of $qualifiedTableName: ${describe(unsupportedKeyColumns)}") + } + + if (unsupported.isNotEmpty()) { + LoggingUtil.uniqueWarn( + log, + "Cannot generate data for some columns of a Cassandra table, as their CQL type is not handled: {}", + "$qualifiedTableName: ${describe(unsupported)}" + ) + } + + return CassandraDbAction(keyspace, table, supported).apply { forceNewTaints() } + } + + /** + * @return the columns a value can be generated for (first), and the ones it cannot (second) + */ + private fun partitionBySupport(columns: List) = + columns.partition { CassandraColumnGeneBuilder.isSupported(it) } + + private fun isPartOfPrimaryKey(column: CassandraColumn) = column.isPartitionKey || column.isClusteringColumn + + private fun describe(columns: List) = columns.joinToString(", ") { "${it.name} ${it.cqlType}" } +} diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRenderer.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRenderer.kt new file mode 100644 index 0000000000..264c42a2a1 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRenderer.kt @@ -0,0 +1,46 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.Gene +import org.evomaster.core.search.gene.UUIDGene +import org.evomaster.core.search.gene.cassandra.CqlDurationGene +import org.evomaster.core.search.gene.datetime.DateGene +import org.evomaster.core.search.gene.datetime.DateTimeGene +import org.evomaster.core.search.gene.datetime.TimeGene +import org.evomaster.core.search.gene.numeric.NumberGene +import org.evomaster.core.search.gene.string.StringGene + +/** + * Renders the value of a gene as a CQL literal, ie as it would be written inside a CQL statement. + * + * This is needed because such literals are inserted verbatim into the INSERT command built on the + * client side, and how a value has to be written depends on its type: text and the temporal types + * are enclosed in single quotes, whereas numbers, booleans, uuids and durations are not. + */ +object CassandraLiteralRenderer { + + private const val SINGLE_QUOTE = "'" + + /** + * In CQL, a single quote inside a text literal is escaped by doubling it. + */ + private const val ESCAPED_SINGLE_QUOTE = "''" + + /** + * @throws IllegalArgumentException if there is no known CQL representation for [gene], which + * should not happen for the genes built by [CassandraColumnGeneBuilder] + */ + fun toCqlLiteral(gene: Gene): String { + + val value = gene.getValueAsRawString() + + return when (gene) { + is StringGene, is DateGene, is TimeGene, is DateTimeGene -> quote(value) + is BooleanGene, is UUIDGene, is NumberGene<*>, is CqlDurationGene -> value + else -> throw IllegalArgumentException("Cannot render a CQL literal for a gene of type ${gene.javaClass.simpleName}") + } + } + + private fun quote(value: String) = + SINGLE_QUOTE + value.replace(SINGLE_QUOTE, ESCAPED_SINGLE_QUOTE) + SINGLE_QUOTE +} diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraTableSchemaParser.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraTableSchemaParser.kt new file mode 100644 index 0000000000..6d917f2540 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraTableSchemaParser.kt @@ -0,0 +1,110 @@ +package org.evomaster.core.database.cassandra + +/** + * Recovers the columns of a Cassandra table from the flat schema description string reported by the + * SUT driver, ie the inverse of how [CassandraColumn]s are rendered on the client side, where each + * column becomes "name type" optionally followed by a " PARTITION KEY" and/or " CLUSTERING" marker, + * and columns are joined with ", ". + */ +object CassandraTableSchemaParser { + + private const val COLUMN_SEPARATOR = ',' + + private const val COLUMN_NAME_TYPE_SEPARATOR = ' ' + + private const val PARTITION_KEY_COLUMN_SUFFIX = " PARTITION KEY" + + private const val CLUSTERING_COLUMN_SUFFIX = " CLUSTERING" + + private const val TYPE_PARAMETERS_START = '<' + + private const val TYPE_PARAMETERS_END = '>' + + /** + * @param tableSchema the description of all the columns of a table, as reported by the SUT driver + * @return the columns described in [tableSchema], in the same order + * @throws IllegalArgumentException if any of the described columns is malformed + */ + fun parse(tableSchema: String): List { + + return splitColumns(tableSchema) + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { parseColumn(it) } + } + + /** + * Splits on the separator between columns, ignoring the separators nested inside a type + * parameter list, as a collection type is itself rendered with them, eg "map". + * + * @throws IllegalArgumentException if the type parameter lists are not balanced, as then there + * is no telling which of the separators are the ones between columns + */ + private fun splitColumns(tableSchema: String): List { + + val columns = mutableListOf() + val current = StringBuilder() + var depth = 0 + + for (c in tableSchema) { + when { + c == TYPE_PARAMETERS_START -> { + depth++ + current.append(c) + } + c == TYPE_PARAMETERS_END -> { + if (depth == 0) { + throw IllegalArgumentException("Unbalanced type parameters in the description" + + " of the columns of a Cassandra table: $tableSchema") + } + depth-- + current.append(c) + } + c == COLUMN_SEPARATOR && depth == 0 -> { + columns.add(current.toString()) + current.clear() + } + else -> current.append(c) + } + } + + if (depth != 0) { + throw IllegalArgumentException("Unbalanced type parameters in the description" + + " of the columns of a Cassandra table: $tableSchema") + } + + columns.add(current.toString()) + + return columns + } + + private fun parseColumn(description: String): CassandraColumn { + + var remainder = description + + /* + The two markers are appended in this order, so they have to be peeled off in reverse. + Both can in principle be present, as they are rendered independently of each other. + */ + val isClusteringColumn = remainder.endsWith(CLUSTERING_COLUMN_SUFFIX) + if (isClusteringColumn) { + remainder = remainder.removeSuffix(CLUSTERING_COLUMN_SUFFIX) + } + val isPartitionKey = remainder.endsWith(PARTITION_KEY_COLUMN_SUFFIX) + if (isPartitionKey) { + remainder = remainder.removeSuffix(PARTITION_KEY_COLUMN_SUFFIX) + } + + val separatorIndex = remainder.indexOf(COLUMN_NAME_TYPE_SEPARATOR) + if (separatorIndex <= 0 || separatorIndex == remainder.length - 1) { + throw IllegalArgumentException("Malformed description of a Cassandra column: $description") + } + + return CassandraColumn( + name = remainder.substring(0, separatorIndex), + cqlType = remainder.substring(separatorIndex + 1), + isPartitionKey = isPartitionKey, + isClusteringColumn = isClusteringColumn + ) + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/output/CassandraWriter.kt b/core/src/main/kotlin/org/evomaster/core/output/CassandraWriter.kt new file mode 100644 index 0000000000..5b796e43a3 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/output/CassandraWriter.kt @@ -0,0 +1,97 @@ +package org.evomaster.core.output + +import org.apache.commons.text.StringEscapeUtils +import org.evomaster.core.database.cassandra.CassandraLiteralRenderer +import org.evomaster.core.search.action.EvaluatedCassandraDbAction + +/** + * Class used to generate the code in the test dealing with insertion of + * data into CASSANDRA databases. + * + * Note that the generated code calls a method to execute the insertions on the SUT controller, which + * does not exist yet, as the wiring of Cassandra into the controller is handled separately. Until + * that is in place, the tests generated for an individual with Cassandra actions do not compile. + */ +object CassandraWriter { + + /** + * generate cassandra insert actions into test case based on [cassandraDbInitialization] + * @param format is the format of tests to be generated + * @param cassandraDbInitialization contains the db actions to be generated + * @param lines is used to save generated textual lines with respects to [cassandraDbInitialization] + * @param groupIndex specifies an index of a group of this [cassandraDbInitialization] + * @param insertionVars is a list of previous variable names of the db actions (Pair.first) and corresponding results (Pair.second) + * @param skipFailure specifies whether to skip failure tests + */ + fun handleCassandraDbInitialization( + format: OutputFormat, + cassandraDbInitialization: List, + lines: Lines, + groupIndex: String = "", + insertionVars: MutableList>, + skipFailure: Boolean + ) { + + if (cassandraDbInitialization.isEmpty() + || cassandraDbInitialization.none { !skipFailure || it.cassandraResult.getInsertExecutionResult() }) { + return + } + + val insertionVar = "insertions_cassandra${groupIndex}" + val insertionVarResult = "${insertionVar}_result" + val previousVar = insertionVars.joinToString(", ") { it.first } + + cassandraDbInitialization + .filter { !skipFailure || it.cassandraResult.getInsertExecutionResult() } + .forEachIndexed { index, evaluatedCassandraDbAction -> + + lines.add( + when { + index == 0 && format.isJava() -> "List $insertionVar = cassandra($previousVar)" + index == 0 && format.isKotlin() -> "val $insertionVar = cassandra($previousVar)" + else -> ".and()" + } + ".insertInto(\"${evaluatedCassandraDbAction.cassandraAction.keyspace}\"" + ", " + + "\"${evaluatedCassandraDbAction.cassandraAction.table}\")" + ) + + if (index == 0) { + lines.indent() + } + + lines.indented { + evaluatedCassandraDbAction.action.seeTopGenes() + .filter { it.isPrintable() } + .forEach { g -> + val printableValue = escape(CassandraLiteralRenderer.toCqlLiteral(g), format) + lines.add(".d(\"${g.name}\", \"$printableValue\")") + } + } + } + + lines.add(".dtos()") + lines.appendSemicolon() + + lines.deindent() + + lines.add( + when { + format.isJava() -> "CassandraInsertionResultsDto " + format.isKotlin() -> "val " + else -> throw IllegalStateException("Not support cassandra insertions generation for $format") + } + "$insertionVarResult = controller.execInsertionsIntoCassandraDatabase($insertionVar)" + ) + lines.appendSemicolon() + + insertionVars.add(insertionVar to insertionVarResult) + } + + /** + * The CQL literal is embedded in a string literal of the generated test, so it has to be + * escaped for the language such a test is written in. + */ + private fun escape(value: String, format: OutputFormat): String { + return StringEscapeUtils.escapeJava(value).let { + if (format.isKotlin()) it.replace("$", "\\$") else it + } + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/search/action/EvaluatedAction.kt b/core/src/main/kotlin/org/evomaster/core/search/action/EvaluatedAction.kt index 7bda63b2ba..b5c16be96b 100644 --- a/core/src/main/kotlin/org/evomaster/core/search/action/EvaluatedAction.kt +++ b/core/src/main/kotlin/org/evomaster/core/search/action/EvaluatedAction.kt @@ -1,11 +1,13 @@ package org.evomaster.core.search.action -import org.evomaster.core.database.sql.SqlAction -import org.evomaster.core.database.sql.SqlActionResult +import org.evomaster.core.database.cassandra.CassandraDbAction +import org.evomaster.core.database.cassandra.CassandraDbActionResult import org.evomaster.core.database.mongo.MongoDbAction import org.evomaster.core.database.mongo.MongoDbActionResult import org.evomaster.core.database.redis.RedisDbAction import org.evomaster.core.database.redis.RedisDbActionResult +import org.evomaster.core.database.sql.SqlAction +import org.evomaster.core.database.sql.SqlActionResult open class EvaluatedAction(val action: Action, val result: ActionResult){ @@ -25,4 +27,6 @@ class EvaluatedDbAction(val sqlAction: SqlAction, val sqlResult: SqlActionResult class EvaluatedMongoDbAction(val mongoAction: MongoDbAction, val mongoResult: MongoDbActionResult) : EvaluatedAction(mongoAction, mongoResult) -class EvaluatedRedisDbAction(val redisAction: RedisDbAction, val redisResult: RedisDbActionResult) : EvaluatedAction(redisAction, redisResult) \ No newline at end of file +class EvaluatedRedisDbAction(val redisAction: RedisDbAction, val redisResult: RedisDbActionResult) : EvaluatedAction(redisAction, redisResult) + +class EvaluatedCassandraDbAction(val cassandraAction: CassandraDbAction, val cassandraResult: CassandraDbActionResult) : EvaluatedAction(cassandraAction, cassandraResult) \ No newline at end of file diff --git a/core/src/main/kotlin/org/evomaster/core/search/gene/cassandra/CqlDurationGene.kt b/core/src/main/kotlin/org/evomaster/core/search/gene/cassandra/CqlDurationGene.kt new file mode 100644 index 0000000000..9b588d47a6 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/gene/cassandra/CqlDurationGene.kt @@ -0,0 +1,107 @@ +package org.evomaster.core.search.gene.cassandra + +import org.evomaster.core.output.OutputFormat +import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.Gene +import org.evomaster.core.search.gene.numeric.IntegerGene +import org.evomaster.core.search.gene.numeric.LongGene +import org.evomaster.core.search.gene.root.CompositeFixedGene +import org.evomaster.core.search.gene.utils.GeneUtils +import org.evomaster.core.search.service.Randomness +import org.evomaster.core.search.service.mutator.genemutation.AdditionalGeneMutationInfo +import org.evomaster.core.search.service.mutator.genemutation.SubsetGeneMutationSelectionStrategy + +/** + * A value of the Cassandra "duration" type, which is composed of a number of months, a number of + * days, and a number of nanoseconds, kept apart from each other rather than reduced to a single + * amount of time, as the length of a month and of a day both depend on the date they are counted + * from. + * + * The three amounts share a single sign, instead of having one each: a duration literal is written + * with at most one leading "-", which applies to the whole value, so a duration mixing signs has no + * representation in CQL. + * + * Note that the representation is not unique, as all the amounts being zero and [negative] being + * true renders "-0mo0d0ns", ie the same value as the positive zero duration spelled differently. + */ +class CqlDurationGene( + name: String, + val months: IntegerGene = IntegerGene("months", min = 0), + val days: IntegerGene = IntegerGene("days", min = 0), + val nanos: LongGene = LongGene("nanos", min = 0), + /** + * Whether the duration is negative, ie the sign shared by the three amounts it is composed of. + * Explicitly defaulted to false, as [BooleanGene] defaults to true. + */ + val negative: BooleanGene = BooleanGene("negative", false) +) : CompositeFixedGene(name, mutableListOf(months, days, nanos, negative)) { + + override fun copyContent(): Gene = CqlDurationGene( + name, + months.copy() as IntegerGene, + days.copy() as IntegerGene, + nanos.copy() as LongGene, + negative.copy() as BooleanGene + ) + + override fun checkForLocallyValidIgnoringChildren(): Boolean { + return true + } + + override fun randomize(randomness: Randomness, tryToForceNewValue: Boolean) { + months.randomize(randomness, tryToForceNewValue) + days.randomize(randomness, tryToForceNewValue) + nanos.randomize(randomness, tryToForceNewValue) + negative.randomize(randomness, tryToForceNewValue) + } + + override fun getValueAsPrintableString( + previousGenes: List, + mode: GeneUtils.EscapeMode?, + targetFormat: OutputFormat?, + extraCheck: Boolean + ): String { + return "\"${getValueAsRawString()}\"" + } + + /** + * @return the duration as it is written in a CQL statement, in the standard Cassandra format. + * All three amounts are always written, so that the literal is well formed even when they are + * all zero. + */ + override fun getValueAsRawString(): String { + val sign = if (negative.value) "-" else "" + return "$sign${months.value}mo${days.value}d${nanos.value}ns" + } + + override fun unsafeCopyValueFrom(other: Gene): Boolean { + if (other !is CqlDurationGene) { + return false + } + + return this.months.unsafeCopyValueFrom(other.months) + && this.days.unsafeCopyValueFrom(other.days) + && this.nanos.unsafeCopyValueFrom(other.nanos) + && this.negative.unsafeCopyValueFrom(other.negative) + } + + override fun containsSameValueAs(other: Gene): Boolean { + if (other !is CqlDurationGene) { + return false + } + + return this.months.containsSameValueAs(other.months) + && this.days.containsSameValueAs(other.days) + && this.nanos.containsSameValueAs(other.nanos) + && this.negative.containsSameValueAs(other.negative) + } + + override fun customShouldApplyShallowMutation( + randomness: Randomness, + selectionStrategy: SubsetGeneMutationSelectionStrategy, + enableAdaptiveGeneMutation: Boolean, + additionalGeneMutationInfo: AdditionalGeneMutationInfo? + ): Boolean { + return false + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilderTest.kt b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilderTest.kt new file mode 100644 index 0000000000..30edf5a0a2 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilderTest.kt @@ -0,0 +1,110 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.Gene +import org.evomaster.core.search.gene.UUIDGene +import org.evomaster.core.search.gene.cassandra.CqlDurationGene +import org.evomaster.core.search.gene.datetime.DateGene +import org.evomaster.core.search.gene.datetime.DateTimeGene +import org.evomaster.core.search.gene.datetime.TimeGene +import org.evomaster.core.search.gene.numeric.BigDecimalGene +import org.evomaster.core.search.gene.numeric.BigIntegerGene +import org.evomaster.core.search.gene.numeric.DoubleGene +import org.evomaster.core.search.gene.numeric.FloatGene +import org.evomaster.core.search.gene.numeric.IntegerGene +import org.evomaster.core.search.gene.numeric.LongGene +import org.evomaster.core.search.gene.string.StringGene +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class CassandraColumnGeneBuilderTest { + + private fun buildFor(cqlType: String): Gene = + CassandraColumnGeneBuilder.buildGene(CassandraColumn("aColumn", cqlType)) + + @Test + fun testTextTypes() { + listOf("ascii", "text", "varchar").forEach { + assertTrue(buildFor(it) is StringGene, "unexpected gene for $it") + } + } + + @Test + fun testIntegerTypes() { + assertTrue(buildFor("tinyint") is IntegerGene) + assertTrue(buildFor("smallint") is IntegerGene) + assertTrue(buildFor("int") is IntegerGene) + } + + @Test + fun testBoundsOfNarrowIntegerTypes() { + val tinyint = buildFor("tinyint") as IntegerGene + assertEquals(Byte.MIN_VALUE.toInt(), tinyint.min) + assertEquals(Byte.MAX_VALUE.toInt(), tinyint.max) + + val smallint = buildFor("smallint") as IntegerGene + assertEquals(Short.MIN_VALUE.toInt(), smallint.min) + assertEquals(Short.MAX_VALUE.toInt(), smallint.max) + } + + @Test + fun testOtherNumericTypes() { + assertTrue(buildFor("bigint") is LongGene) + assertTrue(buildFor("varint") is BigIntegerGene) + assertTrue(buildFor("decimal") is BigDecimalGene) + assertTrue(buildFor("float") is FloatGene) + assertTrue(buildFor("double") is DoubleGene) + } + + @Test + fun testBooleanAndUuidTypes() { + assertTrue(buildFor("boolean") is BooleanGene) + assertTrue(buildFor("uuid") is UUIDGene) + } + + @Test + fun testTemporalTypes() { + assertTrue(buildFor("timestamp") is DateTimeGene) + assertTrue(buildFor("date") is DateGene) + assertTrue(buildFor("time") is TimeGene) + } + + @Test + fun testTypeNameIsNormalized() { + assertTrue(buildFor(" TEXT ") is StringGene) + } + + @Test + fun testGeneKeepsTheNameOfTheColumn() { + val gene = CassandraColumnGeneBuilder.buildGene(CassandraColumn("firstName", "text")) + assertEquals("firstName", gene.name) + } + + @Test + fun testDurationType() { + assertTrue(buildFor("duration") is CqlDurationGene) + } + + /** + * A counter is only writable with an UPDATE, and a timeuuid needs a value that a plain uuid + * gene would not produce, so neither can be given an arbitrary value in an insertion. For the + * other types, it is just that no gene generating a value for them has been written yet. + */ + @Test + fun testUnsupportedTypes() { + listOf("counter", "timeuuid", "blob", "inet", "list", "frozen").forEach { + assertFalse(CassandraColumnGeneBuilder.isSupported(CassandraColumn("aColumn", it)), "$it should not be supported") + assertThrows("no exception for $it") { buildFor(it) } + } + } + + @Test + fun testSupportedTypesAreReportedAsSuch() { + listOf("text", "int", "uuid", "timestamp", "boolean").forEach { + assertTrue(CassandraColumnGeneBuilder.isSupported(CassandraColumn("aColumn", it)), "$it should be supported") + } + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraDbActionTransformerTest.kt b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraDbActionTransformerTest.kt new file mode 100644 index 0000000000..67efab6fe8 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraDbActionTransformerTest.kt @@ -0,0 +1,71 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.core.search.gene.numeric.IntegerGene +import org.evomaster.core.search.gene.string.StringGene +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CassandraDbActionTransformerTest { + + private fun anAction(): CassandraDbAction { + return CassandraDbAction( + "ks", "users", + listOf(CassandraColumn("name", "text"), CassandraColumn("age", "int")), + listOf(StringGene("name", "Alice"), IntegerGene("age", 42)) + ) + } + + @Test + fun testNoAction() { + assertTrue(CassandraDbActionTransformer.transform(listOf()).insertions.isEmpty()) + } + + @Test + fun testKeyspaceAndTableAreReported() { + val dto = CassandraDbActionTransformer.transform(listOf(anAction())) + + assertEquals(1, dto.insertions.size) + assertEquals("ks", dto.insertions[0].keyspaceName) + assertEquals("users", dto.insertions[0].tableName) + } + + @Test + fun testOneEntryPerColumnWithItsCqlLiteral() { + val dto = CassandraDbActionTransformer.transform(listOf(anAction())) + + val data = dto.insertions[0].data + assertEquals(2, data.size) + + assertEquals("name", data[0].columnName) + assertEquals("'Alice'", data[0].printableValue) + + assertEquals("age", data[1].columnName) + assertEquals("42", data[1].printableValue) + } + + @Test + fun testSeveralActionsKeepTheirOrder() { + val other = CassandraDbAction( + "ks", "events", + listOf(CassandraColumn("note", "text")), + listOf(StringGene("note", "hello")) + ) + + val dto = CassandraDbActionTransformer.transform(listOf(anAction(), other)) + + assertEquals(2, dto.insertions.size) + assertEquals("users", dto.insertions[0].tableName) + assertEquals("events", dto.insertions[1].tableName) + } + + @Test + fun testActionWithNoColumn() { + val action = CassandraDbAction("ks", "empty", listOf(), listOf()) + + val dto = CassandraDbActionTransformer.transform(listOf(action)) + + assertEquals(1, dto.insertions.size) + assertTrue(dto.insertions[0].data.isEmpty()) + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilderTest.kt b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilderTest.kt new file mode 100644 index 0000000000..6841e85809 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilderTest.kt @@ -0,0 +1,111 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.core.search.gene.UUIDGene +import org.evomaster.core.search.gene.string.StringGene +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class CassandraInsertBuilderTest { + + private val builder = CassandraInsertBuilder() + + @Test + fun testOneGenePerColumn() { + val action = builder.createCassandraInsertionAction("ks", "users", "id uuid PARTITION KEY, name text") + + assertEquals(listOf("id", "name"), action.seeTopGenes().map { it.name }) + assertTrue(action.seeTopGenes()[0] is UUIDGene) + assertTrue(action.seeTopGenes()[1] is StringGene) + } + + @Test + fun testKeyspaceAndTableAreKept() { + val action = builder.createCassandraInsertionAction("ks", "users", "id uuid PARTITION KEY") + + assertEquals("ks", action.keyspace) + assertEquals("users", action.table) + } + + @Test + fun testActionName() { + val action = builder.createCassandraInsertionAction("ks", "users", "id uuid PARTITION KEY") + + assertEquals("CASSANDRA_Insert_ks_users", action.getName()) + } + + @Test + fun testKeyRolesAreKept() { + val action = builder.createCassandraInsertionAction( + "ks", "events", "id uuid PARTITION KEY, created timestamp CLUSTERING, note text") + + assertTrue(action.columns[0].isPartitionKey) + assertTrue(action.columns[1].isClusteringColumn) + assertTrue(!action.columns[2].isPartitionKey && !action.columns[2].isClusteringColumn) + } + + /** + * No value can be generated for a column whose type is not handled, so it is just left out of + * the insertion instead of preventing the other columns from being inserted. + */ + @Test + fun testColumnsWithUnsupportedTypeAreSkipped() { + val action = builder.createCassandraInsertionAction( + "ks", "users", "id uuid PARTITION KEY, picture blob, name text") + + assertEquals(listOf("id", "name"), action.seeTopGenes().map { it.name }) + assertEquals(listOf("id", "name"), action.columns.map { it.name }) + } + + /** + * An insertion with no column at all could only be rejected, so none is built. + */ + @Test + fun testTableWithNoSupportedColumnIsRejected() { + assertThrows { + builder.createCassandraInsertionAction("ks", "blobs", "content blob") + } + assertFalse(builder.canBuildInsertionFor("content blob")) + } + + /** + * Cassandra requires a full primary key in an INSERT, so an insertion leaving out one of the + * columns composing it could only be rejected. + */ + @Test + fun testTableWithUnsupportedPartitionKeyIsRejected() { + assertThrows { + builder.createCassandraInsertionAction("ks", "users", "id blob PARTITION KEY, name text") + } + assertFalse(builder.canBuildInsertionFor("id blob PARTITION KEY, name text")) + } + + @Test + fun testTableWithUnsupportedClusteringColumnIsRejected() { + val schema = "id uuid PARTITION KEY, at blob CLUSTERING, note text" + + assertThrows { + builder.createCassandraInsertionAction("ks", "events", schema) + } + assertFalse(builder.canBuildInsertionFor(schema)) + } + + @Test + fun testInsertionCanBeBuiltWhenOnlyRegularColumnsAreSkipped() { + assertTrue(builder.canBuildInsertionFor("id uuid PARTITION KEY, picture blob, name text")) + assertTrue(builder.canBuildInsertionFor("id uuid PARTITION KEY, name text")) + } + + @Test + fun testCopyKeepsTheColumns() { + val action = builder.createCassandraInsertionAction("ks", "users", "id uuid PARTITION KEY, name text") + val copy = action.copy() as CassandraDbAction + + assertEquals(action.keyspace, copy.keyspace) + assertEquals(action.table, copy.table) + assertEquals(action.columns, copy.columns) + assertEquals(action.seeTopGenes().map { it.name }, copy.seeTopGenes().map { it.name }) + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRendererTest.kt b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRendererTest.kt new file mode 100644 index 0000000000..eae3be0013 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRendererTest.kt @@ -0,0 +1,102 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.ObjectGene +import org.evomaster.core.search.gene.UUIDGene +import org.evomaster.core.search.gene.cassandra.CqlDurationGene +import org.evomaster.core.search.gene.datetime.DateGene +import org.evomaster.core.search.gene.datetime.DateTimeGene +import org.evomaster.core.search.gene.datetime.TimeGene +import org.evomaster.core.search.gene.numeric.DoubleGene +import org.evomaster.core.search.gene.numeric.IntegerGene +import org.evomaster.core.search.gene.numeric.LongGene +import org.evomaster.core.search.gene.string.StringGene +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class CassandraLiteralRendererTest { + + @Test + fun testTextIsQuoted() { + assertEquals("'Alice'", CassandraLiteralRenderer.toCqlLiteral(StringGene("name", "Alice"))) + } + + @Test + fun testEmptyTextIsQuoted() { + assertEquals("''", CassandraLiteralRenderer.toCqlLiteral(StringGene("name", ""))) + } + + /** + * In CQL, a single quote inside a text literal is escaped by doubling it. + */ + @Test + fun testSingleQuoteInsideTextIsDoubled() { + assertEquals("'l''Alice'", CassandraLiteralRenderer.toCqlLiteral(StringGene("name", "l'Alice"))) + } + + @Test + fun testSeveralSingleQuotesInsideTextAreDoubled() { + assertEquals("'''a'''", CassandraLiteralRenderer.toCqlLiteral(StringGene("name", "'a'"))) + } + + @Test + fun testNumbersAreNotQuoted() { + assertEquals("42", CassandraLiteralRenderer.toCqlLiteral(IntegerGene("age", 42))) + assertEquals("-7", CassandraLiteralRenderer.toCqlLiteral(IntegerGene("delta", -7))) + assertEquals("123", CassandraLiteralRenderer.toCqlLiteral(LongGene("amount", 123L))) + } + + @Test + fun testDoubleIsNotQuoted() { + val gene = DoubleGene("ratio", 1.5) + assertEquals(gene.getValueAsRawString(), CassandraLiteralRenderer.toCqlLiteral(gene)) + } + + @Test + fun testBooleanIsNotQuoted() { + assertEquals("true", CassandraLiteralRenderer.toCqlLiteral(BooleanGene("flag", true))) + assertEquals("false", CassandraLiteralRenderer.toCqlLiteral(BooleanGene("flag", false))) + } + + /** + * A uuid literal is written without quotes in CQL. + */ + @Test + fun testUuidIsNotQuoted() { + val gene = UUIDGene("id") + assertEquals(gene.getValueAsRawString(), CassandraLiteralRenderer.toCqlLiteral(gene)) + } + + @Test + fun testTemporalValuesAreQuoted() { + listOf(DateTimeGene("created"), DateGene("day"), TimeGene("moment")).forEach { + assertEquals("'${it.getValueAsRawString()}'", CassandraLiteralRenderer.toCqlLiteral(it)) + } + } + + /** + * A duration literal is written without quotes in CQL, sign included. + */ + @Test + fun testDurationIsNotQuoted() { + val gene = CqlDurationGene( + "elapsed", + months = IntegerGene("months", 1), + days = IntegerGene("days", 2), + nanos = LongGene("nanos", 3L) + ) + + assertEquals("1mo2d3ns", CassandraLiteralRenderer.toCqlLiteral(gene)) + + gene.negative.value = true + assertEquals("-1mo2d3ns", CassandraLiteralRenderer.toCqlLiteral(gene)) + } + + @Test + fun testGeneWithNoCqlRepresentationIsRejected() { + assertThrows { + CassandraLiteralRenderer.toCqlLiteral(ObjectGene("obj", listOf())) + } + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraTableSchemaParserTest.kt b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraTableSchemaParserTest.kt new file mode 100644 index 0000000000..feed13518f --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraTableSchemaParserTest.kt @@ -0,0 +1,99 @@ +package org.evomaster.core.database.cassandra + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class CassandraTableSchemaParserTest { + + @Test + fun testEmptySchema() { + assertTrue(CassandraTableSchemaParser.parse("").isEmpty()) + } + + @Test + fun testSingleRegularColumn() { + val columns = CassandraTableSchemaParser.parse("name text") + + assertEquals(1, columns.size) + assertEquals(CassandraColumn("name", "text"), columns[0]) + } + + @Test + fun testPartitionKeyColumn() { + val columns = CassandraTableSchemaParser.parse("id uuid PARTITION KEY") + + assertEquals(listOf(CassandraColumn("id", "uuid", isPartitionKey = true)), columns) + } + + @Test + fun testClusteringColumn() { + val columns = CassandraTableSchemaParser.parse("created timestamp CLUSTERING") + + assertEquals(listOf(CassandraColumn("created", "timestamp", isClusteringColumn = true)), columns) + } + + @Test + fun testColumnMarkedBothAsPartitionKeyAndClustering() { + val columns = CassandraTableSchemaParser.parse("id uuid PARTITION KEY CLUSTERING") + + assertEquals( + listOf(CassandraColumn("id", "uuid", isPartitionKey = true, isClusteringColumn = true)), + columns + ) + } + + @Test + fun testSeveralColumnsKeepTheirOrder() { + val columns = CassandraTableSchemaParser.parse("id uuid PARTITION KEY, name text, created timestamp CLUSTERING") + + assertEquals( + listOf( + CassandraColumn("id", "uuid", isPartitionKey = true), + CassandraColumn("name", "text"), + CassandraColumn("created", "timestamp", isClusteringColumn = true) + ), + columns + ) + } + + /** + * The type of a collection is itself rendered with the same separator used between columns. + */ + @Test + fun testCollectionTypeIsNotSplit() { + val columns = CassandraTableSchemaParser.parse("id uuid PARTITION KEY, data map") + + assertEquals(2, columns.size) + assertEquals(CassandraColumn("data", "map"), columns[1]) + } + + @Test + fun testNestedCollectionTypeIsNotSplit() { + val columns = CassandraTableSchemaParser.parse("data map>>, name text") + + assertEquals(2, columns.size) + assertEquals(CassandraColumn("data", "map>>"), columns[0]) + assertEquals(CassandraColumn("name", "text"), columns[1]) + } + + @Test + fun testColumnWithNoTypeIsRejected() { + assertThrows { CassandraTableSchemaParser.parse("name") } + } + + /** + * With unbalanced type parameters, there is no telling which of the separators are the ones + * between columns, so the description is rejected instead of being split at the wrong places. + */ + @Test + fun testUnclosedTypeParametersAreRejected() { + assertThrows { CassandraTableSchemaParser.parse("tags map { CassandraTableSchemaParser.parse("a text>, b int") } + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/output/CassandraWriterTest.kt b/core/src/test/kotlin/org/evomaster/core/output/CassandraWriterTest.kt new file mode 100644 index 0000000000..b8b4f40487 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/output/CassandraWriterTest.kt @@ -0,0 +1,141 @@ +package org.evomaster.core.output + +import org.evomaster.core.database.cassandra.CassandraColumn +import org.evomaster.core.database.cassandra.CassandraDbAction +import org.evomaster.core.database.cassandra.CassandraDbActionResult +import org.evomaster.core.search.action.EvaluatedCassandraDbAction +import org.evomaster.core.search.gene.Gene +import org.evomaster.core.search.gene.numeric.IntegerGene +import org.evomaster.core.search.gene.string.StringGene +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CassandraWriterTest { + + private var counter = 0 + + private fun makeEvaluated( + keyspace: String = "ks", + table: String = "users", + columns: List = listOf(CassandraColumn("name", "text")), + genes: List = listOf(StringGene("name", "Alice")), + success: Boolean = true + ): EvaluatedCassandraDbAction { + val action = CassandraDbAction(keyspace, table, columns, genes) + action.setLocalId("test-cassandra-action-${counter++}") + val result = CassandraDbActionResult(action.getLocalId()).also { it.setInsertExecutionResult(success) } + return EvaluatedCassandraDbAction(action, result) + } + + private fun write( + actions: List, + format: OutputFormat = OutputFormat.KOTLIN_JUNIT_5, + insertionVars: MutableList> = mutableListOf(), + skipFailure: Boolean = false, + groupIndex: String = "" + ): String { + val lines = Lines(format) + CassandraWriter.handleCassandraDbInitialization(format, actions, lines, groupIndex, insertionVars, skipFailure) + return lines.toString() + } + + @Test + fun testEmptyListGeneratesNothing() { + assertTrue(write(emptyList()).isBlank()) + } + + @Test + fun testAllFailedWithSkipFailureGeneratesNothing() { + assertTrue(write(listOf(makeEvaluated(success = false)), skipFailure = true).isBlank()) + } + + @Test + fun testFailedInsertionIsKeptWhenNotSkipping() { + assertTrue(write(listOf(makeEvaluated(success = false))).contains(".insertInto(\"ks\", \"users\")")) + } + + @Test + fun testKotlinOutput() { + val output = write(listOf(makeEvaluated())) + + assertTrue(output.contains("val insertions_cassandra = cassandra()")) + assertTrue(output.contains(".insertInto(\"ks\", \"users\")")) + assertTrue(output.contains(".d(\"name\", \"'Alice'\")")) + assertTrue(output.contains(".dtos()")) + assertTrue(output.contains("val insertions_cassandra_result = controller.execInsertionsIntoCassandraDatabase(insertions_cassandra)")) + } + + @Test + fun testJavaOutput() { + val output = write(listOf(makeEvaluated()), format = OutputFormat.JAVA_JUNIT_5) + + assertTrue(output.contains("List insertions_cassandra = cassandra()")) + assertTrue(output.contains("CassandraInsertionResultsDto insertions_cassandra_result = controller.execInsertionsIntoCassandraDatabase(insertions_cassandra)")) + } + + @Test + fun testOneColumnPerGene() { + val output = write( + listOf( + makeEvaluated( + columns = listOf(CassandraColumn("name", "text"), CassandraColumn("age", "int")), + genes = listOf(StringGene("name", "Alice"), IntegerGene("age", 42)) + ) + ) + ) + + assertTrue(output.contains(".d(\"name\", \"'Alice'\")")) + assertTrue(output.contains(".d(\"age\", \"42\")")) + } + + @Test + fun testSeveralActionsAreChained() { + val output = write(listOf(makeEvaluated(), makeEvaluated(table = "events"))) + + assertTrue(output.contains(".insertInto(\"ks\", \"users\")")) + assertTrue(output.contains(".and().insertInto(\"ks\", \"events\")")) + } + + /** + * The CQL literal ends up inside a string literal of the generated test, so it has to be + * escaped for the language that test is written in. + */ + @Test + fun testValueIsEscapedForTheGeneratedTest() { + val output = write( + listOf(makeEvaluated(genes = listOf(StringGene("name", "a\"b")))) + ) + + assertTrue(output.contains(".d(\"name\", \"'a\\\"b'\")")) + } + + @Test + fun testDollarIsEscapedInKotlinOnly() { + val genes = listOf(StringGene("name", "a\$b")) + + val kotlin = write(listOf(makeEvaluated(genes = genes)), format = OutputFormat.KOTLIN_JUNIT_5) + assertTrue(kotlin.contains("\\$")) + + val java = write(listOf(makeEvaluated(genes = genes)), format = OutputFormat.JAVA_JUNIT_5) + assertFalse(java.contains("\\$")) + } + + @Test + fun testInsertionVarIsRegisteredForFollowingGroups() { + val insertionVars = mutableListOf>() + + write(listOf(makeEvaluated()), insertionVars = insertionVars) + + assertTrue(insertionVars.contains("insertions_cassandra" to "insertions_cassandra_result")) + } + + @Test + fun testPreviousInsertionVarsArePassedOn() { + val insertionVars = mutableListOf("insertions" to "insertionsresult") + + val output = write(listOf(makeEvaluated()), insertionVars = insertionVars, groupIndex = "1") + + assertTrue(output.contains("val insertions_cassandra1 = cassandra(insertions)")) + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/search/gene/GeneNumberOfGenesTest.kt b/core/src/test/kotlin/org/evomaster/core/search/gene/GeneNumberOfGenesTest.kt index 7c3a1823fc..a090467d41 100644 --- a/core/src/test/kotlin/org/evomaster/core/search/gene/GeneNumberOfGenesTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/search/gene/GeneNumberOfGenesTest.kt @@ -13,7 +13,7 @@ class GeneNumberOfGenesTest : AbstractGeneTest() { This number should not change, unless you explicitly add/remove any gene. if so, update this number accordingly */ - assertEquals(95, geneClasses.size) + assertEquals(96, geneClasses.size) } } diff --git a/core/src/test/kotlin/org/evomaster/core/search/gene/GeneSamplerForTests.kt b/core/src/test/kotlin/org/evomaster/core/search/gene/GeneSamplerForTests.kt index 280fa1b628..4528d42cd5 100644 --- a/core/src/test/kotlin/org/evomaster/core/search/gene/GeneSamplerForTests.kt +++ b/core/src/test/kotlin/org/evomaster/core/search/gene/GeneSamplerForTests.kt @@ -2,6 +2,7 @@ package org.evomaster.core.search.gene import org.evomaster.client.java.instrumentation.shared.TaintInputName import org.evomaster.core.parser.RegexType +import org.evomaster.core.search.gene.cassandra.CqlDurationGene import org.evomaster.core.search.gene.collection.* import org.evomaster.core.search.gene.datetime.* import org.evomaster.core.search.gene.interfaces.ComparableGene @@ -186,6 +187,9 @@ object GeneSamplerForTests { // Mongo genes ObjectIdGene::class -> sampleMongoObjectIdGene(rand) as T + // Cassandra genes + CqlDurationGene::class -> sampleCqlDurationGene(rand) as T + // JSON Patch genes JsonPatchDocumentGene::class -> sampleJsonPatchDocumentGene(rand) as T JsonPatchPathOnlyGene::class -> sampleJsonPatchPathOnlyGene(rand) as T @@ -419,6 +423,10 @@ object GeneSamplerForTests { return ObjectIdGene("rand ObjectIdGene ${rand.nextInt()}") } + private fun sampleCqlDurationGene(rand: Randomness): CqlDurationGene { + return CqlDurationGene("rand CqlDurationGene ${rand.nextInt()}") + } + fun sampleBackReferenceRxGene(rand: Randomness): BackReferenceRxGene { val captureGroup = sampleDisjunctionListRxGene(rand) // as we do not allow to mutate the inner captureGroup gene using the backref gene we must first initialize it diff --git a/core/src/test/kotlin/org/evomaster/core/search/gene/cassandra/CqlDurationGeneTest.kt b/core/src/test/kotlin/org/evomaster/core/search/gene/cassandra/CqlDurationGeneTest.kt new file mode 100644 index 0000000000..4c00d4fd66 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/search/gene/cassandra/CqlDurationGeneTest.kt @@ -0,0 +1,73 @@ +package org.evomaster.core.search.gene.cassandra + +import org.evomaster.core.search.gene.numeric.IntegerGene +import org.evomaster.core.search.gene.numeric.LongGene +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CqlDurationGeneTest { + + private fun duration(months: Int, days: Int, nanos: Long, negative: Boolean = false) = + CqlDurationGene( + "elapsed", + months = IntegerGene("months", months), + days = IntegerGene("days", days), + nanos = LongGene("nanos", nanos) + ).apply { this.negative.value = negative } + + @Test + fun testValueIsRenderedWithTheThreeUnits() { + assertEquals("1mo2d3ns", duration(1, 2, 3L).getValueAsRawString()) + } + + /** + * All the amounts are written even when zero, so that the literal is never empty. + */ + @Test + fun testZeroDuration() { + assertEquals("0mo0d0ns", duration(0, 0, 0L).getValueAsRawString()) + } + + /** + * A duration literal carries at most one sign, applying to the whole value, as a duration + * mixing signs cannot be written in CQL. + */ + @Test + fun testNegativeDurationHasASingleLeadingSign() { + assertEquals("-1mo2d3ns", duration(1, 2, 3L, negative = true).getValueAsRawString()) + } + + @Test + fun testDurationIsPositiveByDefault() { + assertFalse(CqlDurationGene("elapsed").negative.value) + } + + @Test + fun testCopyKeepsAllTheComponents() { + val gene = duration(1, 2, 3L, negative = true) + val copy = gene.copy() as CqlDurationGene + + assertEquals(gene.getValueAsRawString(), copy.getValueAsRawString()) + assertTrue(gene.containsSameValueAs(copy)) + } + + @Test + fun testDurationsDifferingInOneComponentAreNotTheSame() { + val gene = duration(1, 2, 3L) + + assertFalse(gene.containsSameValueAs(duration(9, 2, 3L))) + assertFalse(gene.containsSameValueAs(duration(1, 9, 3L))) + assertFalse(gene.containsSameValueAs(duration(1, 2, 9L))) + assertFalse(gene.containsSameValueAs(duration(1, 2, 3L, negative = true))) + } + + @Test + fun testCopyValueFrom() { + val gene = CqlDurationGene("elapsed") + + assertTrue(gene.copyValueFrom(duration(1, 2, 3L, negative = true))) + assertEquals("-1mo2d3ns", gene.getValueAsRawString()) + } +} \ No newline at end of file