diff --git a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/cassandra/CassandraColumnDto.java b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/cassandra/CassandraColumnDto.java new file mode 100644 index 0000000000..665957290d --- /dev/null +++ b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/cassandra/CassandraColumnDto.java @@ -0,0 +1,70 @@ +package org.evomaster.client.java.controller.api.dto.database.cassandra; + +/** + * A single column of a Cassandra table, as read from the driver's own metadata on the SUT side and + * reported to the core process. + */ +public class CassandraColumnDto { + + private String name; + + /** + * The type of the column, as named in CQL, eg "text", "int", "map<text, int>". + */ + private String cqlType; + + /** + * Whether this column is part of the partition key of the table. + */ + private boolean partitionKey; + + /** + * Whether this column is one of the clustering columns of the table. + */ + private boolean clusteringColumn; + + /** + * Needed to deserialize the DTO, as it is sent over HTTP. + */ + public CassandraColumnDto() { + } + + public CassandraColumnDto(String name, String cqlType, boolean partitionKey, boolean clusteringColumn) { + this.name = name; + this.cqlType = cqlType; + this.partitionKey = partitionKey; + this.clusteringColumn = clusteringColumn; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCqlType() { + return cqlType; + } + + public void setCqlType(String cqlType) { + this.cqlType = cqlType; + } + + public boolean isPartitionKey() { + return partitionKey; + } + + public void setPartitionKey(boolean partitionKey) { + this.partitionKey = partitionKey; + } + + public boolean isClusteringColumn() { + return clusteringColumn; + } + + public void setClusteringColumn(boolean clusteringColumn) { + this.clusteringColumn = clusteringColumn; + } +} \ No newline at end of file diff --git a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/cassandra/CassandraTableSchemaDto.java b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/cassandra/CassandraTableSchemaDto.java new file mode 100644 index 0000000000..4a0160e783 --- /dev/null +++ b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/cassandra/CassandraTableSchemaDto.java @@ -0,0 +1,57 @@ +package org.evomaster.client.java.controller.api.dto.database.cassandra; + +import java.util.ArrayList; +import java.util.List; + +/** + * The shape of a Cassandra table, ie the columns a row of it is composed of, as read from the + * driver's own metadata on the SUT side. It is what the core process bases the data it generates + * for such a table on. + */ +public class CassandraTableSchemaDto { + + private String keyspaceName; + + private String tableName; + + /** + * All the columns of the table, in the order the driver reports them. + */ + private List columns = new ArrayList<>(); + + /** + * Needed to deserialize the DTO, as it is sent over HTTP. + */ + public CassandraTableSchemaDto() { + } + + public CassandraTableSchemaDto(String keyspaceName, String tableName, List columns) { + this.keyspaceName = keyspaceName; + this.tableName = tableName; + this.columns = columns; + } + + public String getKeyspaceName() { + return keyspaceName; + } + + public void setKeyspaceName(String keyspaceName) { + this.keyspaceName = keyspaceName; + } + + public String getTableName() { + return tableName; + } + + public void setTableName(String tableName) { + this.tableName = tableName; + } + + public List getColumns() { + return columns; + } + + public void setColumns(List columns) { + this.columns = columns; + } +} \ No newline at end of file diff --git a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/execution/CassandraFailedQuery.java b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/execution/CassandraFailedQuery.java index 57e4f84438..555d56bcb0 100644 --- a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/execution/CassandraFailedQuery.java +++ b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/execution/CassandraFailedQuery.java @@ -1,5 +1,7 @@ package org.evomaster.client.java.controller.api.dto.database.execution; +import org.evomaster.client.java.controller.api.dto.database.cassandra.CassandraTableSchemaDto; + import java.util.Objects; /** @@ -10,17 +12,24 @@ public class CassandraFailedQuery { /** * The keyspace the table belongs to. */ - private final String keyspaceName; + private String keyspaceName; /** * The table the query targeted. */ - private final String tableName; + private String tableName; + /** + * The shape of the table's rows, null when it was never captured, ie when no query referencing + * the table was intercepted while the schema of its tables was being tracked. + */ + private CassandraTableSchemaDto tableSchema; + /** - * The schema of the table's rows, if known. + * Needed to deserialize the DTO, as it is sent over HTTP. */ - private String tableSchema; + public CassandraFailedQuery() { + } - public CassandraFailedQuery(String keyspaceName, String tableName, String tableSchema) { + public CassandraFailedQuery(String keyspaceName, String tableName, CassandraTableSchemaDto tableSchema) { this.keyspaceName = Objects.requireNonNull(keyspaceName); this.tableName = Objects.requireNonNull(tableName); this.tableSchema = tableSchema; @@ -30,11 +39,23 @@ public String getKeyspaceName() { return keyspaceName; } + public void setKeyspaceName(String keyspaceName) { + this.keyspaceName = keyspaceName; + } + public String getTableName() { return tableName; } - public String getTableSchema() { + public void setTableName(String tableName) { + this.tableName = tableName; + } + + public CassandraTableSchemaDto getTableSchema() { return tableSchema; } + + public void setTableSchema(CassandraTableSchemaDto tableSchema) { + this.tableSchema = tableSchema; + } } \ No newline at end of file diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/cassandra/CassandraHandler.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/cassandra/CassandraHandler.java index cfc398fde9..a503dab118 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/cassandra/CassandraHandler.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/cassandra/CassandraHandler.java @@ -1,5 +1,7 @@ package org.evomaster.client.java.controller.internal.db.cassandra; +import org.evomaster.client.java.controller.api.dto.database.cassandra.CassandraColumnDto; +import org.evomaster.client.java.controller.api.dto.database.cassandra.CassandraTableSchemaDto; import org.evomaster.client.java.controller.api.dto.database.execution.CassandraExecutionsDto; import org.evomaster.client.java.controller.api.dto.database.execution.CassandraFailedQuery; import org.evomaster.client.java.controller.cassandra.calculator.CassandraHeuristicsCalculator; @@ -44,13 +46,6 @@ public class CassandraHandler { private static final String METHOD_AS_INTERNAL = "asInternal"; private static final String METHOD_GET_OBJECT = "getObject"; - /* - Constants used during schema description - */ - private static final String PARTITION_KEY_COLUMN_SUFFIX = " PARTITION KEY"; - private static final String CLUSTERING_COLUMN_SUFFIX = " CLUSTERING"; - private static final char COLUMN_NAME_TYPE_SEPARATOR = ' '; - private static final String SELECT_ALL_PREFIX = "SELECT * FROM "; private static final char KEYSPACE_TABLE_SEPARATOR = '.'; @@ -185,32 +180,27 @@ public CassandraExecutionsDto getExecutionDto() { private CassandraFailedQuery extractRelevantInfo(ExecutedCqlCommand info) { CassandraTableMetadata schema = tableSchemas.get(new TableKey(info.getKeyspaceName(), info.getTableName())); - String tableSchema = schema != null ? describeTableSchema(schema) : null; + CassandraTableSchemaDto tableSchema = schema != null ? toDto(schema) : null; return new CassandraFailedQuery(info.getKeyspaceName(), info.getTableName(), tableSchema); } /** - * Renders a table's columns as {@code name type [PARTITION KEY|CLUSTERING]} entries, so the - * schema can be attached to a {@link CassandraFailedQuery} (a plain string, to keep + * Converts a captured table schema into the DTO it is reported with, which is what keeps * controller-api free of a dependency on the instrumentation module's - * {@link CassandraTableMetadata}). + * {@link CassandraTableMetadata}. */ - private static String describeTableSchema(CassandraTableMetadata schema) { - return schema.getColumns().stream() - .map(CassandraHandler::describeColumn) - .collect(Collectors.joining(", ")); + private static CassandraTableSchemaDto toDto(CassandraTableMetadata schema) { + List columns = schema.getColumns().stream() + .map(CassandraHandler::toDto) + .collect(Collectors.toList()); + + return new CassandraTableSchemaDto(schema.getKeyspaceName(), schema.getTableName(), columns); } - private static String describeColumn(CassandraColumnMetadata column) { - StringBuilder description = new StringBuilder(column.getName()).append(COLUMN_NAME_TYPE_SEPARATOR).append(column.getCqlType()); - if (column.isPartitionKey()) { - description.append(PARTITION_KEY_COLUMN_SUFFIX); - } - if (column.isClusteringColumn()) { - description.append(CLUSTERING_COLUMN_SUFFIX); - } - return description.toString(); + private static CassandraColumnDto toDto(CassandraColumnMetadata column) { + return new CassandraColumnDto(column.getName(), column.getCqlType(), + column.isPartitionKey(), column.isClusteringColumn()); } private CqlDistanceWithMetrics computeQueryDistance(ExecutedCqlCommand info) { 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/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/cassandra/CassandraHandlerTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/cassandra/CassandraHandlerTest.java index 806e6b2364..f8355c11bd 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/cassandra/CassandraHandlerTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/cassandra/CassandraHandlerTest.java @@ -2,6 +2,8 @@ import com.datastax.oss.driver.api.core.CqlSession; import com.datastax.oss.driver.api.core.cql.ResultSet; +import org.evomaster.client.java.controller.api.dto.database.cassandra.CassandraColumnDto; +import org.evomaster.client.java.controller.api.dto.database.cassandra.CassandraTableSchemaDto; import org.evomaster.client.java.controller.api.dto.database.execution.CassandraExecutionsDto; import org.evomaster.client.java.instrumentation.ExecutedCqlCommand; import org.evomaster.client.java.instrumentation.cassandra.CassandraColumnMetadata; @@ -81,6 +83,23 @@ private static CassandraTableMetadata tableSchema(String tableName) { Collections.singletonList(new CassandraColumnMetadata("id", "int", true, false))); } + /** + * Asserts that the reported schema is the one {@link #tableSchema(String)} captured, ie that + * every part of the captured metadata reached the DTO. + */ + private void assertReportedSchemaOfTable(CassandraTableSchemaDto reported) { + assertNotNull(reported); + assertEquals(KEYSPACE, reported.getKeyspaceName()); + assertEquals(TABLE, reported.getTableName()); + assertEquals(1, reported.getColumns().size()); + + CassandraColumnDto column = reported.getColumns().get(0); + assertEquals("id", column.getName()); + assertEquals("int", column.getCqlType()); + assertTrue(column.isPartitionKey()); + assertFalse(column.isClusteringColumn()); + } + @Test public void testSelectDistance_zeroWhenRowMatches() { session.execute("INSERT INTO " + KEYSPACE + "." + TABLE + " (id, age, name) VALUES (1, 30, 'John Doe')"); @@ -168,7 +187,7 @@ public void testEmptyTable_recordedAsFailedQueryWithSchema() { assertEquals(1, dto.failedQueries.size()); assertEquals(KEYSPACE, dto.failedQueries.get(0).getKeyspaceName()); assertEquals(TABLE, dto.failedQueries.get(0).getTableName()); - assertEquals("id int PARTITION KEY", dto.failedQueries.get(0).getTableSchema()); + assertReportedSchemaOfTable(dto.failedQueries.get(0).getTableSchema()); } /** @@ -226,7 +245,7 @@ public void testReset_preservesTableSchema() { CassandraExecutionsDto dto = handler.getExecutionDto(); assertEquals(1, dto.failedQueries.size()); // schema was captured before reset() and must still be attached afterwards - assertEquals("id int PARTITION KEY", dto.failedQueries.get(0).getTableSchema()); + assertReportedSchemaOfTable(dto.failedQueries.get(0).getTableSchema()); } @Test 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..55f8fd6c32 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumn.kt @@ -0,0 +1,41 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.client.java.controller.api.dto.database.cassandra.CassandraColumnDto + +/** + * A single column of a Cassandra table, as reported by the SUT driver in the schema of the table a + * failed CQL query targeted. + */ +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 +) { + + companion object { + + /** + * @param dto the column of a table, as reported by the SUT driver + */ + fun fromDto(dto: CassandraColumnDto) = CassandraColumn( + name = dto.name, + cqlType = dto.cqlType, + isPartitionKey = dto.isPartitionKey, + isClusteringColumn = dto.isClusteringColumn + ) + } +} \ No newline at end of file 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..65659e3742 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilder.kt @@ -0,0 +1,92 @@ +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 { + + private const val ASCII_TYPE = "ascii" + private const val TEXT_TYPE = "text" + private const val VARCHAR_TYPE = "varchar" + private const val TINYINT_TYPE = "tinyint" + private const val SMALLINT_TYPE = "smallint" + private const val INT_TYPE = "int" + private const val BIGINT_TYPE = "bigint" + private const val VARINT_TYPE = "varint" + private const val DECIMAL_TYPE = "decimal" + private const val FLOAT_TYPE = "float" + private const val DOUBLE_TYPE = "double" + private const val BOOLEAN_TYPE = "boolean" + private const val UUID_TYPE = "uuid" + private const val TIMESTAMP_TYPE = "timestamp" + private const val DATE_TYPE = "date" + private const val TIME_TYPE = "time" + private const val DURATION_TYPE = "duration" + + /** + * 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_TYPE to { name -> StringGene(name) }, + TEXT_TYPE to { name -> StringGene(name) }, + VARCHAR_TYPE to { name -> StringGene(name) }, + TINYINT_TYPE to { name -> IntegerGene(name, min = Byte.MIN_VALUE.toInt(), max = Byte.MAX_VALUE.toInt()) }, + SMALLINT_TYPE to { name -> IntegerGene(name, min = Short.MIN_VALUE.toInt(), max = Short.MAX_VALUE.toInt()) }, + INT_TYPE to { name -> IntegerGene(name) }, + BIGINT_TYPE to { name -> LongGene(name) }, + VARINT_TYPE to { name -> BigIntegerGene(name) }, + DECIMAL_TYPE to { name -> BigDecimalGene(name) }, + FLOAT_TYPE to { name -> FloatGene(name) }, + DOUBLE_TYPE to { name -> DoubleGene(name) }, + BOOLEAN_TYPE to { name -> BooleanGene(name) }, + UUID_TYPE 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_TYPE to { name -> DateTimeGene(name, onlyValid = true) }, + DATE_TYPE to { name -> DateGene(name, onlyValidDates = true) }, + TIME_TYPE to { name -> TimeGene(name, onlyValidTimes = true) }, + DURATION_TYPE 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..8379a57300 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilder.kt @@ -0,0 +1,89 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.client.java.controller.api.dto.database.cassandra.CassandraTableSchemaDto +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 schema of that table + * reported by the SUT driver. + */ +class CassandraInsertBuilder { + + companion object { + private val log: Logger = LoggerFactory.getLogger(CassandraInsertBuilder::class.java) + } + + /** + * @param tableSchema the schema 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: CassandraTableSchemaDto): Boolean { + + val (supported, unsupported) = partitionBySupport(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. + * + * @param tableSchema the schema of the table to insert a row into, which is also what says + * where such a table is, ie its keyspace and its name + * @throws IllegalArgumentException if no insertion that could be executed can be built for the + * table, as verifiable beforehand with [canBuildInsertionFor] + */ + fun createCassandraInsertionAction(tableSchema: CassandraTableSchemaDto): CassandraDbAction { + + val (supported, unsupported) = partitionBySupport(tableSchema) + + val qualifiedTableName = "${tableSchema.keyspaceName}.${tableSchema.tableName}" + + 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(tableSchema.keyspaceName, tableSchema.tableName, supported) + .apply { forceNewTaints() } + } + + /** + * @return the columns a value can be generated for (first), and the ones it cannot (second) + */ + private fun partitionBySupport(tableSchema: CassandraTableSchemaDto) = + tableSchema.columns + .map { CassandraColumn.fromDto(it) } + .partition { CassandraColumnGeneBuilder.isSupported(it) } + + private fun isPartOfPrimaryKey(column: CassandraColumn) = column.isPartitionKey || column.isClusteringColumn + + private fun describe(columns: List) = columns.joinToString(", ") { "${it.name} ${it.cqlType}" } +} \ No newline at end of file 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/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..ec743b4c06 --- /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 [isNegative] 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 isNegative: BooleanGene = BooleanGene("negative", false) +) : CompositeFixedGene(name, mutableListOf(months, days, nanos, isNegative)) { + + override fun copyContent(): Gene = CqlDurationGene( + name, + months.copy() as IntegerGene, + days.copy() as IntegerGene, + nanos.copy() as LongGene, + isNegative.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) + isNegative.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 (isNegative.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.isNegative.unsafeCopyValueFrom(other.isNegative) + } + + 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.isNegative.containsSameValueAs(other.isNegative) + } + + 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..2ca9b0dc46 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilderTest.kt @@ -0,0 +1,139 @@ +package org.evomaster.core.database.cassandra + +import org.evomaster.client.java.controller.api.dto.database.cassandra.CassandraColumnDto +import org.evomaster.client.java.controller.api.dto.database.cassandra.CassandraTableSchemaDto +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() + + private fun schema(keyspace: String, table: String, vararg columns: CassandraColumnDto) = + CassandraTableSchemaDto(keyspace, table, columns.toList()) + + private fun column(name: String, cqlType: String) = CassandraColumnDto(name, cqlType, false, false) + + private fun partitionKey(name: String, cqlType: String) = CassandraColumnDto(name, cqlType, true, false) + + private fun clusteringColumn(name: String, cqlType: String) = CassandraColumnDto(name, cqlType, false, true) + + @Test + fun testOneGenePerColumn() { + val action = builder.createCassandraInsertionAction( + schema("ks", "users", partitionKey("id", "uuid"), column("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( + schema("ks", "users", partitionKey("id", "uuid"))) + + assertEquals("ks", action.keyspace) + assertEquals("users", action.table) + } + + @Test + fun testActionName() { + val action = builder.createCassandraInsertionAction( + schema("ks", "users", partitionKey("id", "uuid"))) + + assertEquals("CASSANDRA_Insert_ks_users", action.getName()) + } + + @Test + fun testKeyRolesAreKept() { + val action = builder.createCassandraInsertionAction( + schema("ks", "events", + partitionKey("id", "uuid"), + clusteringColumn("created", "timestamp"), + column("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( + schema("ks", "users", + partitionKey("id", "uuid"), + column("picture", "blob"), + column("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() { + val schema = schema("ks", "blobs", column("content", "blob")) + + assertThrows { builder.createCassandraInsertionAction(schema) } + assertFalse(builder.canBuildInsertionFor(schema)) + } + + /** + * 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() { + val schema = schema("ks", "users", partitionKey("id", "blob"), column("name", "text")) + + assertThrows { builder.createCassandraInsertionAction(schema) } + assertFalse(builder.canBuildInsertionFor(schema)) + } + + @Test + fun testTableWithUnsupportedClusteringColumnIsRejected() { + val schema = schema("ks", "events", + partitionKey("id", "uuid"), + clusteringColumn("at", "blob"), + column("note", "text")) + + assertThrows { builder.createCassandraInsertionAction(schema) } + assertFalse(builder.canBuildInsertionFor(schema)) + } + + @Test + fun testInsertionCanBeBuiltWhenOnlyRegularColumnsAreSkipped() { + assertTrue(builder.canBuildInsertionFor( + schema("ks", "users", + partitionKey("id", "uuid"), + column("picture", "blob"), + column("name", "text")))) + + assertTrue(builder.canBuildInsertionFor( + schema("ks", "users", partitionKey("id", "uuid"), column("name", "text")))) + } + + @Test + fun testCopyKeepsTheColumns() { + val action = builder.createCassandraInsertionAction( + schema("ks", "users", partitionKey("id", "uuid"), column("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 }) + } +} \ No newline at end of file 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..00a06452b6 --- /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.isNegative.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/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..324719a302 --- /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.isNegative.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").isNegative.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