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 8abfdd7561..715d88b69f 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,8 @@ 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, elapsed duration)"); + " (id int PRIMARY KEY, name text, elapsed duration, ip inet," + + " tags set, scores list, favs map)"); } @AfterAll @@ -97,6 +98,49 @@ public void testInsertDuration() { assertEquals(2, connection.execute("SELECT * FROM " + KEYSPACE + "." + TABLE).all().size()); } + /** + * An IP address is written as a quoted literal, whereas a collection is written as a delimited + * sequence of the literals of what it holds, with a list between square brackets and a set and + * a map between braces. This is what CassandraLiteralRenderer in the core module relies on when + * rendering a CqlCollectionGene and an InetGene, so it is checked here against a real Cassandra. + */ + @Test + public void testInsertInetAndCollections() { + List insertions = CassandraDsl.cassandra() + .insertInto(KEYSPACE, TABLE) + .d("id", "1") + .d("ip", "'127.0.0.1'") + .d("tags", "{'pet', 'cute'}") + .d("scores", "[17, 4, 2]") + .d("favs", "{'fruit': 3}") + .dtos(); + + CassandraInsertionResultsDto resultsDto = CassandraScriptRunner.executeInsert(connection, insertions); + + assertTrue(resultsDto.executionResults.get(0)); + assertEquals(1, connection.execute("SELECT * FROM " + KEYSPACE + "." + TABLE).all().size()); + } + + /** + * A collection gene can be randomized into an empty one, so the literal it renders has to be + * accepted as well. Note that Cassandra stores an empty collection as null. + */ + @Test + public void testInsertEmptyCollections() { + List insertions = CassandraDsl.cassandra() + .insertInto(KEYSPACE, TABLE) + .d("id", "1") + .d("tags", "{}") + .d("scores", "[]") + .d("favs", "{}") + .dtos(); + + CassandraInsertionResultsDto resultsDto = CassandraScriptRunner.executeInsert(connection, insertions); + + assertTrue(resultsDto.executionResults.get(0)); + assertEquals(1, connection.execute("SELECT * FROM " + KEYSPACE + "." + TABLE).all().size()); + } + @Test public void testInsertionFailureDoesNotStopFollowingInsertions() { 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 index 65659e3742..e93b20b516 100644 --- a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilder.kt +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilder.kt @@ -4,20 +4,26 @@ 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.collection.ArrayGene +import org.evomaster.core.search.gene.collection.FixedMapGene 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.network.InetGene 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. * + * The collection types are handled by recursing on the types parameterizing them, so a column is + * only supported when all of the types composing it are. + * * 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 + * - no gene generating a value of that type has been written yet, ie blob, the tuples, the vectors * and the user defined types. */ object CassandraColumnGeneBuilder { @@ -35,11 +41,19 @@ object CassandraColumnGeneBuilder { private const val DOUBLE_TYPE = "double" private const val BOOLEAN_TYPE = "boolean" private const val UUID_TYPE = "uuid" + private const val INET_TYPE = "inet" private const val TIMESTAMP_TYPE = "timestamp" private const val DATE_TYPE = "date" private const val TIME_TYPE = "time" private const val DURATION_TYPE = "duration" + /** + * The name given to the genes generating what a collection holds. Such genes are not bound to a + * column of their own, and the elements of a collection are written with no name in a CQL + * literal, so the name is only there to identify them while debugging. + */ + private const val ELEMENT_GENE_NAME = "element" + /** * 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 @@ -59,6 +73,11 @@ object CassandraColumnGeneBuilder { DOUBLE_TYPE to { name -> DoubleGene(name) }, BOOLEAN_TYPE to { name -> BooleanGene(name) }, UUID_TYPE to { name -> UUIDGene(name) }, + /* + Only IPv4 addresses are generated for now, although the CQL type also accepts IPv6 ones, as + that is what InetGene builds. The same restriction already applies to the SQL types. + */ + INET_TYPE to { name -> InetGene(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. @@ -70,23 +89,62 @@ object CassandraColumnGeneBuilder { ) /** - * @return whether a gene can be built for [column], ie whether its CQL type is one of the - * scalar types handled here + * @return whether a gene can be built for [column], ie whether its CQL type is one of the ones + * handled here, or a collection of such types */ - fun isSupported(column: CassandraColumn) = normalize(column.cqlType) in GENE_BUILDERS + fun isSupported(column: CassandraColumn) = isSupported(normalize(column.cqlType)) /** * @throws IllegalArgumentException if the CQL type of [column] is not handled, as verifiable * beforehand with [isSupported] */ - fun buildGene(column: CassandraColumn): Gene { + fun buildGene(column: CassandraColumn): Gene = buildGene(column.name, normalize(column.cqlType)) + + private fun isSupported(cqlType: String): Boolean { + + val collection = CqlCollectionTypeParser.parse(cqlType) ?: return cqlType in GENE_BUILDERS + + return collection.parameters.all { isSupported(normalize(it)) } + } + + /** + * @param cqlType a normalized CQL type + */ + private fun buildGene(name: String, cqlType: String): Gene { + + CqlCollectionTypeParser.parse(cqlType)?.let { return buildCollectionGene(name, it) } + + val builder = GENE_BUILDERS[cqlType] + ?: throw IllegalArgumentException("Cannot handle the CQL type $cqlType of column $name") - val builder = GENE_BUILDERS[normalize(column.cqlType)] - ?: throw IllegalArgumentException("Cannot handle the CQL type of column $column") + return builder(name) + } - return builder(column.name) + /** + * A CQL collection is generated with the gene already handling that kind of collection, ie an + * [ArrayGene] for a list and a set, and a [FixedMapGene] for a map. How a value of it is written + * in a CQL literal is recovered from the gene itself by [CassandraLiteralRenderer], a set being + * the array asking for unique elements. + */ + private fun buildCollectionGene(name: String, type: CqlCollectionType): Gene { + + return when (type.name) { + CqlCollectionTypeParser.LIST_TYPE -> ArrayGene(name, template = elementGene(type, 0)) + /* + Cassandra collapses the repeated elements of a set literal into a single one, so + generating them would just be wasted search effort. + */ + CqlCollectionTypeParser.SET_TYPE -> + ArrayGene(name, template = elementGene(type, 0), uniqueElements = true) + CqlCollectionTypeParser.MAP_TYPE -> + FixedMapGene(name, key = elementGene(type, 0), value = elementGene(type, 1)) + else -> throw IllegalArgumentException("Not a supported CQL collection type: ${type.name}") + } } + private fun elementGene(type: CqlCollectionType, index: Int) = + buildGene(ELEMENT_GENE_NAME, normalize(type.parameters[index])) + private fun normalize(cqlType: String) = cqlType.trim().lowercase() } 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 index 264c42a2a1..c2c03f3140 100644 --- a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRenderer.kt +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRenderer.kt @@ -4,9 +4,13 @@ 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.collection.ArrayGene +import org.evomaster.core.search.gene.collection.FixedMapGene +import org.evomaster.core.search.gene.collection.PairGene 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.network.InetGene import org.evomaster.core.search.gene.numeric.NumberGene import org.evomaster.core.search.gene.string.StringGene @@ -14,8 +18,9 @@ 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. + * client side, and how a value has to be written depends on its type: text, the temporal types and + * the IP addresses are enclosed in single quotes, whereas numbers, booleans, uuids and durations are + * not, and the collections are written as a delimited sequence of the literals of what they hold. */ object CassandraLiteralRenderer { @@ -26,21 +31,68 @@ object CassandraLiteralRenderer { */ private const val ESCAPED_SINGLE_QUOTE = "''" + private const val ELEMENT_SEPARATOR = ", " + + private const val KEY_VALUE_SEPARATOR = ": " + + /** + * How a list is delimited in a CQL literal, eg "[1, 2]". + */ + private const val LIST_OPENING = "[" + private const val LIST_CLOSING = "]" + + /** + * How a set and a map are delimited in a CQL literal, eg "{1, 2}" and "{'a': 1}". + */ + private const val SET_OR_MAP_OPENING = "{" + private const val SET_OR_MAP_CLOSING = "}" + /** * @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 + is StringGene, is DateGene, is TimeGene, is DateTimeGene, is InetGene -> quote(gene.getValueAsRawString()) + is BooleanGene, is UUIDGene, is NumberGene<*>, is CqlDurationGene -> gene.getValueAsRawString() + is FixedMapGene<*, *> -> renderMap(gene) + is ArrayGene<*> -> renderArray(gene) else -> throw IllegalArgumentException("Cannot render a CQL literal for a gene of type ${gene.javaClass.simpleName}") } } + /** + * The elements are rendered by recursing, rather than by asking the collection gene to print + * itself, as each of them has to be written the way a CQL literal of its own type is, eg with a + * text enclosed in single quotes rather than in the double quotes a gene prints itself with. + */ + private fun renderMap(gene: FixedMapGene<*, *>): String { + + return gene.getViewOfChildren() + .map { renderEntry(it as PairGene<*, *>) } + .joinToString(ELEMENT_SEPARATOR, SET_OR_MAP_OPENING, SET_OR_MAP_CLOSING) + } + + /** + * A list and a set are both generated with an [ArrayGene], and are told apart by whether it asks + * for unique elements, which is how [CassandraColumnGeneBuilder] builds a set: Cassandra keeps a + * single copy of the repeated elements of a set anyway. The two are written with different + * delimiters, so the distinction has to be recovered here. + */ + private fun renderArray(gene: ArrayGene<*>): String { + + val opening = if (gene.uniqueElements) SET_OR_MAP_OPENING else LIST_OPENING + val closing = if (gene.uniqueElements) SET_OR_MAP_CLOSING else LIST_CLOSING + + return gene.getViewOfChildren() + .map { toCqlLiteral(it) } + .joinToString(ELEMENT_SEPARATOR, opening, closing) + } + + private fun renderEntry(entry: PairGene<*, *>) = + toCqlLiteral(entry.first) + KEY_VALUE_SEPARATOR + toCqlLiteral(entry.second) + private fun quote(value: String) = SINGLE_QUOTE + value.replace(SINGLE_QUOTE, ESCAPED_SINGLE_QUOTE) + SINGLE_QUOTE -} +} \ No newline at end of file diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CqlCollectionTypeParser.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CqlCollectionTypeParser.kt new file mode 100644 index 0000000000..d2c176888e --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CqlCollectionTypeParser.kt @@ -0,0 +1,103 @@ +package org.evomaster.core.database.cassandra + +/** + * Recognizes the CQL collection types and recovers their parameters, ie the CQL types of what they + * hold, so that a gene can be built for each of them. + */ +object CqlCollectionTypeParser { + + const val LIST_TYPE = "list" + const val SET_TYPE = "set" + const val MAP_TYPE = "map" + + /** + * Key -> the name of a CQL collection type. + * Value -> how many CQL types parameterize it, ie one for the elements of a list and of a set, + * two for the keys and the values of a map. + * + * Being the single place where the collection types are enumerated, it is also what tells them + * apart from the other parameterized types, ie the tuples and the vectors. + */ + private val ARITIES: Map = mapOf(LIST_TYPE to 1, SET_TYPE to 1, MAP_TYPE to 2) + + private const val FROZEN_PREFIX = "frozen" + + private const val TYPE_PARAMETER_SEPARATOR = ',' + + /** + * @param cqlType a normalized, ie trimmed and lower case, CQL type name + * @return the kind and parameters of [cqlType] if it is a collection type, null if it is not, + * which covers both the scalar types and the parameterized ones that are not collections, ie + * the tuples and the vectors + * @throws IllegalArgumentException if [cqlType] is a collection type carrying the wrong number + * of parameters, or if its type parameters are not balanced + */ + fun parse(cqlType: String): CqlCollectionType? { + + val unfrozen = stripFrozen(cqlType) + + if (!unfrozen.endsWith(CqlTypeParameters.END)) { + return null + } + + val parametersStart = unfrozen.indexOf(CqlTypeParameters.START) + if (parametersStart <= 0) { + return null + } + + val name = unfrozen.substring(0, parametersStart).trim() + + val arity = ARITIES[name] ?: return null + + val parameters = CqlTypeParameters + .splitAtTopLevel(unfrozen.substring(parametersStart + 1, unfrozen.length - 1), TYPE_PARAMETER_SEPARATOR) + .map { it.trim() } + + if (parameters.size != arity) { + throw IllegalArgumentException("A CQL $name is parameterized by $arity" + + " type(s), but ${parameters.size} were given: $cqlType") + } + + return CqlCollectionType(name, parameters) + } + + /** + * A collection is written as "frozen<...>" when its value is stored as a single immutable one, + * which is required of the collections nested inside another one and of the ones composing a + * primary key. The distinction does not affect how a value of it is written in an insertion, + * so the marker is just peeled off. + * + * Note that the SUT is not expected to report a frozen type in the first place, as the driver + * metadata the schema is read from is asked for the type without it. This is only here so that + * a type written by hand is handled the same way. + */ + private fun stripFrozen(cqlType: String): String { + + var current = cqlType + + while (current.startsWith(FROZEN_PREFIX + CqlTypeParameters.START) && current.endsWith(CqlTypeParameters.END)) { + current = current.substring(FROZEN_PREFIX.length + 1, current.length - 1).trim() + } + + return current + } +} + +/** + * A CQL collection type, ie its name and the CQL types of what it holds: the type of the elements + * for a list and a set, and the types of the keys and of the values for a map. + */ +data class CqlCollectionType( + + /** + * The name of the type in CQL, ie one of [CqlCollectionTypeParser.LIST_TYPE], + * [CqlCollectionTypeParser.SET_TYPE] and [CqlCollectionTypeParser.MAP_TYPE]. + */ + val name: String, + + /** + * The CQL types parameterizing the collection, in the order they are written in, ie the type of + * the elements for a list and a set, and the types of the keys and then of the values for a map. + */ + val parameters: List +) \ No newline at end of file diff --git a/core/src/main/kotlin/org/evomaster/core/database/cassandra/CqlTypeParameters.kt b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CqlTypeParameters.kt new file mode 100644 index 0000000000..f949d37ae8 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/cassandra/CqlTypeParameters.kt @@ -0,0 +1,57 @@ +package org.evomaster.core.database.cassandra + +/** + * Splitting of a CQL fragment on a separator, ignoring the separators nested inside a type + * parameter list, as a collection type is itself written with them, eg "map". + */ +internal object CqlTypeParameters { + + const val START = '<' + + const val END = '>' + + /** + * @param text the fragment to split + * @param separator the character to split [text] on, when not nested inside a type parameter list + * @return the parts of [text], in the same order, not trimmed + * @throws IllegalArgumentException if the type parameter lists in [text] are not balanced, as + * then there is no telling which of the separators are the ones being split on + */ + fun splitAtTopLevel(text: String, separator: Char): List { + + val parts = mutableListOf() + val current = StringBuilder() + var depth = 0 + + for (c in text) { + when { + c == START -> { + depth++ + current.append(c) + } + c == END -> { + if (depth == 0) { + throw IllegalArgumentException(unbalancedMessage(text)) + } + depth-- + current.append(c) + } + c == separator && depth == 0 -> { + parts.add(current.toString()) + current.clear() + } + else -> current.append(c) + } + } + + if (depth != 0) { + throw IllegalArgumentException(unbalancedMessage(text)) + } + + parts.add(current.toString()) + + return parts + } + + private fun unbalancedMessage(text: String) = "Unbalanced type parameters in a CQL fragment: $text" +} \ 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 index 30edf5a0a2..5cf8cb31e7 100644 --- a/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilderTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraColumnGeneBuilderTest.kt @@ -4,6 +4,9 @@ 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.collection.ArrayGene +import org.evomaster.core.search.gene.collection.FixedMapGene +import org.evomaster.core.search.gene.network.InetGene import org.evomaster.core.search.gene.datetime.DateGene import org.evomaster.core.search.gene.datetime.DateTimeGene import org.evomaster.core.search.gene.datetime.TimeGene @@ -88,14 +91,84 @@ class CassandraColumnGeneBuilderTest { assertTrue(buildFor("duration") is CqlDurationGene) } + @Test + fun testInetType() { + assertTrue(buildFor("inet") is InetGene) + } + + @Test + fun testListType() { + val gene = buildFor("list") as ArrayGene<*> + + assertFalse(gene.uniqueElements) + assertTrue(gene.template is IntegerGene) + } + + /** + * Cassandra collapses the repeated elements of a set, so generating them is wasted effort. + * Note that the gene only asks for unique elements, without guaranteeing them: the check is + * skipped altogether for the element types [ArrayGene] cannot compare, and nothing keeps an + * element from being mutated into the value of another one afterwards. + */ + @Test + fun testSetTypeAsksForUniqueElements() { + val gene = buildFor("set") as ArrayGene<*> + + assertTrue(gene.uniqueElements) + assertTrue(gene.template is StringGene) + } + + @Test + fun testMapType() { + val gene = buildFor("map") as FixedMapGene<*, *> + + assertTrue(gene.template.first is StringGene) + assertTrue(gene.template.second is IntegerGene) + } + + @Test + fun testNestedCollectionType() { + val gene = buildFor("list>") as ArrayGene<*> + + assertFalse(gene.uniqueElements) + + val element = gene.template as ArrayGene<*> + assertTrue(element.uniqueElements) + assertTrue(element.template is IntegerGene) + } + + /** + * Whether a collection is frozen does not change how a value of it is written in an insertion. + */ + @Test + fun testFrozenCollectionIsHandledAsAPlainOne() { + val gene = buildFor("frozen>") as ArrayGene<*> + + assertFalse(gene.uniqueElements) + assertTrue(gene.template is IntegerGene) + } + + /** + * No value can be generated for a collection when none can be generated for what it holds. + */ + @Test + fun testCollectionOfAnUnsupportedTypeIsNotSupported() { + listOf("list", "map", "set>", "list>").forEach { + assertFalse(CassandraColumnGeneBuilder.isSupported(CassandraColumn("aColumn", it)), "$it should not be supported") + assertThrows("no exception for $it") { buildFor(it) } + } + } + /** * 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. + * The tuples and the vectors are written with type parameters without being collections, so + * they are the ones the handling of the collection types has to avoid mistaking for one. */ @Test fun testUnsupportedTypes() { - listOf("counter", "timeuuid", "blob", "inet", "list", "frozen").forEach { + listOf("counter", "timeuuid", "blob", "frozen", "tuple", "vector").forEach { assertFalse(CassandraColumnGeneBuilder.isSupported(CassandraColumn("aColumn", it)), "$it should not be supported") assertThrows("no exception for $it") { buildFor(it) } } @@ -103,7 +176,7 @@ class CassandraColumnGeneBuilderTest { @Test fun testSupportedTypesAreReportedAsSuch() { - listOf("text", "int", "uuid", "timestamp", "boolean").forEach { + listOf("text", "int", "uuid", "timestamp", "boolean", "inet", "list", "map").forEach { assertTrue(CassandraColumnGeneBuilder.isSupported(CassandraColumn("aColumn", it)), "$it should be supported") } } 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 index 2ca9b0dc46..b0394958f3 100644 --- a/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilderTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraInsertBuilderTest.kt @@ -3,6 +3,7 @@ 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.collection.ArrayGene import org.evomaster.core.search.gene.string.StringGene import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -125,6 +126,22 @@ class CassandraInsertBuilderTest { schema("ks", "users", partitionKey("id", "uuid"), column("name", "text")))) } + /** + * Cassandra only allows a frozen collection in a primary key, and a frozen type is reported as + * a plain one, so a collection in that position is handled as any other supported column. + */ + @Test + fun testTableWithACollectionAsPartitionKeyIsAccepted() { + val schema = schema("ks", "images", partitionKey("tags", "set"), column("v", "int")) + + assertTrue(builder.canBuildInsertionFor(schema)) + + val action = builder.createCassandraInsertionAction(schema) + + assertEquals(listOf("tags", "v"), action.seeTopGenes().map { it.name }) + assertTrue(action.seeTopGenes()[0] is ArrayGene<*>) + } + @Test fun testCopyKeepsTheColumns() { val action = builder.createCassandraInsertionAction( 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 index 00a06452b6..7b64c1c005 100644 --- a/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRendererTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CassandraLiteralRendererTest.kt @@ -4,6 +4,10 @@ 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.collection.ArrayGene +import org.evomaster.core.search.gene.collection.FixedMapGene +import org.evomaster.core.search.gene.collection.PairGene +import org.evomaster.core.search.gene.network.InetGene import org.evomaster.core.search.gene.datetime.DateGene import org.evomaster.core.search.gene.datetime.DateTimeGene import org.evomaster.core.search.gene.datetime.TimeGene @@ -93,6 +97,89 @@ class CassandraLiteralRendererTest { assertEquals("-1mo2d3ns", CassandraLiteralRenderer.toCqlLiteral(gene)) } + /** + * An IP address is written as a quoted literal in CQL, ie an unquoted one is a syntax error. + */ + @Test + fun testInetIsQuoted() { + val gene = InetGene("ip") + + assertEquals("'${gene.getValueAsRawString()}'", CassandraLiteralRenderer.toCqlLiteral(gene)) + } + + private fun arrayGeneOf(vararg values: Int) = arrayGeneOf(false, *values) + + private fun setGeneOf(vararg values: Int) = arrayGeneOf(true, *values) + + private fun arrayGeneOf(uniqueElements: Boolean, vararg values: Int): ArrayGene { + + val gene = ArrayGene("elements", template = IntegerGene("element"), uniqueElements = uniqueElements) + values.forEach { gene.addElement(IntegerGene("element", it)) } + + return gene + } + + @Test + fun testListIsWrittenBetweenSquareBrackets() { + val gene = arrayGeneOf(1, 2) + + assertEquals("[1, 2]", CassandraLiteralRenderer.toCqlLiteral(gene)) + } + + @Test + fun testSetIsWrittenBetweenBraces() { + val gene = setGeneOf(1, 2) + + assertEquals("{1, 2}", CassandraLiteralRenderer.toCqlLiteral(gene)) + } + + @Test + fun testMapIsWrittenAsKeysAndValues() { + val content = FixedMapGene("entries", key = StringGene("element"), value = IntegerGene("element")) + content.addElement(PairGene("entry", StringGene("element", "a"), IntegerGene("element", 1))) + + assertEquals("{'a': 1}", CassandraLiteralRenderer.toCqlLiteral(content)) + } + + /** + * The elements have to be written the way a CQL literal of their own type is, which is what + * would be lost by asking the collection gene to print itself instead of recursing. + */ + @Test + fun testTextElementsAreQuotedAndEscaped() { + val content = ArrayGene("elements", template = StringGene("element"), uniqueElements = true) + content.addElement(StringGene("element", "a")) + content.addElement(StringGene("element", "l'Alice")) + + assertEquals("{'a', 'l''Alice'}", CassandraLiteralRenderer.toCqlLiteral(content)) + } + + @Test + fun testEmptyCollectionsAreWrittenWithTheirDelimitersOnly() { + assertEquals("[]", CassandraLiteralRenderer.toCqlLiteral(arrayGeneOf())) + + assertEquals("{}", CassandraLiteralRenderer.toCqlLiteral(setGeneOf())) + + assertEquals("{}", CassandraLiteralRenderer.toCqlLiteral( + FixedMapGene("entries", key = StringGene("element"), value = IntegerGene("element")))) + } + + @Test + fun testNestedCollectionIsRenderedByRecursing() { + val content = FixedMapGene( + "entries", + key = StringGene("element"), + value = arrayGeneOf() + ) + content.addElement(PairGene( + "entry", + StringGene("element", "a"), + arrayGeneOf(1, 2) + )) + + assertEquals("{'a': [1, 2]}", CassandraLiteralRenderer.toCqlLiteral(content)) + } + @Test fun testGeneWithNoCqlRepresentationIsRejected() { assertThrows { diff --git a/core/src/test/kotlin/org/evomaster/core/database/cassandra/CqlCollectionTypeParserTest.kt b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CqlCollectionTypeParserTest.kt new file mode 100644 index 0000000000..bce4fb6d1d --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/database/cassandra/CqlCollectionTypeParserTest.kt @@ -0,0 +1,81 @@ +package org.evomaster.core.database.cassandra + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class CqlCollectionTypeParserTest { + + @Test + fun testList() { + assertEquals(CqlCollectionType(CqlCollectionTypeParser.LIST_TYPE, listOf("int")), CqlCollectionTypeParser.parse("list")) + } + + @Test + fun testSet() { + assertEquals(CqlCollectionType(CqlCollectionTypeParser.SET_TYPE, listOf("text")), CqlCollectionTypeParser.parse("set")) + } + + @Test + fun testMap() { + assertEquals( + CqlCollectionType(CqlCollectionTypeParser.MAP_TYPE, listOf("text", "int")), + CqlCollectionTypeParser.parse("map") + ) + } + + /** + * The comma separating the parameters of the nested collection is not one of the separators + * between the parameters of the map. + */ + @Test + fun testNestedCollectionIsNotSplitOn() { + assertEquals( + CqlCollectionType(CqlCollectionTypeParser.MAP_TYPE, listOf("text", "map")), + CqlCollectionTypeParser.parse("map>") + ) + } + + @Test + fun testFrozenMarkerIsPeeledOff() { + assertEquals(CqlCollectionType(CqlCollectionTypeParser.LIST_TYPE, listOf("int")), CqlCollectionTypeParser.parse("frozen>")) + } + + @Test + fun testFrozenMarkerOfANestedCollectionIsKeptForTheRecursion() { + assertEquals( + CqlCollectionType(CqlCollectionTypeParser.MAP_TYPE, listOf("text", "frozen>")), + CqlCollectionTypeParser.parse("map>>") + ) + } + + @Test + fun testScalarTypeIsNotACollection() { + listOf("int", "text", "duration", "inet").forEach { + assertNull(CqlCollectionTypeParser.parse(it), "$it should not be a collection") + } + } + + /** + * A tuple, a vector and a frozen user defined type are all written with type parameters without + * being collections, so they have to be told apart from the ones that are. + */ + @Test + fun testOtherParameterizedTypesAreNotCollections() { + listOf("tuple", "vector", "frozen").forEach { + assertNull(CqlCollectionTypeParser.parse(it), "$it should not be a collection") + } + } + + @Test + fun testWrongNumberOfParametersIsRejected() { + assertThrows { CqlCollectionTypeParser.parse("map") } + assertThrows { CqlCollectionTypeParser.parse("list") } + } + + @Test + fun testUnbalancedTypeParametersAreRejected() { + assertThrows { CqlCollectionTypeParser.parse("map") } + } +} \ No newline at end of file