Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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<text>, scores list<int>, favs map<text, int>)");
}

@AfterAll
Expand Down Expand Up @@ -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<CassandraInsertionDto> 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<CassandraInsertionDto> 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() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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()

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@ 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

/**
* 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 {

Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Int> = 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<String>
)
Loading
Loading