diff --git a/client-java/controller/pom.xml b/client-java/controller/pom.xml index bbd9c52185..33ade035a2 100644 --- a/client-java/controller/pom.xml +++ b/client-java/controller/pom.xml @@ -193,6 +193,11 @@ libthrift test + + org.apache.cassandra + java-driver-core + test + org.mongodb bson diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/calculator/CassandraHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/calculator/CassandraHeuristicsCalculator.java new file mode 100644 index 0000000000..c74259062c --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/calculator/CassandraHeuristicsCalculator.java @@ -0,0 +1,101 @@ +package org.evomaster.client.java.controller.cassandra.calculator; + +import org.evomaster.client.java.controller.cassandra.model.CassandraRow; +import org.evomaster.client.java.controller.cassandra.operations.CqlQueryOperation; +import org.evomaster.client.java.controller.cassandra.parser.CqlParserUtils; +import org.evomaster.client.java.distance.heuristics.DistanceHelper; +import org.evomaster.client.java.distance.heuristics.Truthness; +import org.evomaster.client.java.distance.heuristics.TruthnessUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Calculator designed to compute a branch-distance-based heuristic for a CQL (Cassandra Query Language) query, + * given the rows returned by executing that query against the database. + *

+ * The distance reflects how far the query's WHERE condition is from being satisfied by + * the available data and is used to guide the search towards inserting data in the database + * that makes the executed queries return non-empty results during the execution of generated tests. + */ +public class CassandraHeuristicsCalculator { + + public static final double C = DistanceHelper.H_NOT_NULL; + public static final double C_BETTER = 0.15; + + public static final Truthness TRUE_TRUTHNESS = new Truthness(1.0, C); + public static final Truthness FALSE_TRUTHNESS = new Truthness(C, 1.0); + public static final Truthness FALSE_TRUTHNESS_BETTER = new Truthness(C_BETTER, 1.0); + + private final CassandraOperationEvaluator evaluator = new CassandraOperationEvaluator(); + + /** + * Computes the heuristic distance of the given CQL query with respect to the provided rows. + * A distance of {@code 0.0} means the query is satisfied, while values closer + * to {@code 1.0} indicate the query is further from being satisfied. + * + * @param cqlQuery the CQL query to evaluate + * @param returnedRows all rows in the table the query targets, used to evaluate how close + * the query's condition is to matching at least one row + * @return a distance value in {@code [0.0, 1.0]}, where {@code 0.0} represents the best case + */ + public double computeDistance(String cqlQuery, Iterable returnedRows) { + return 1.0 - computeHQuery(cqlQuery, returnedRows).getOfTrue(); + } + + private Truthness computeHQuery(String cqlQuery, Iterable returnedRows) { + if (!CqlParserUtils.canParseCqlCommand(cqlQuery)) { + return FALSE_TRUTHNESS; + } + + List rows = toList(returnedRows); + + org.evomaster.client.java.controller.cassandra.parser.CqlParser.RootContext root = + CqlParserUtils.parseCqlCommand(cqlQuery); + CqlQueryOperation whereOp = CqlParserUtils.getWhereOperation(root); + + Truthness hRowSet = computeHRowSet(rows); + + if (whereOp == null) { + return hRowSet; + } + + Truthness hCondition = computeHCondition(whereOp, rows); + return TruthnessUtils.buildAndAggregationTruthness(hRowSet, hCondition); + } + + private Truthness computeHRowSet(List rows) { + return TruthnessUtils.getTruthnessToEmpty(rows.size()).invert(); + } + + private Truthness computeHCondition(CqlQueryOperation condition, + List rows) { + if (rows.isEmpty()) { + return FALSE_TRUTHNESS; + } + + double maxOfTrue = 0.0; + for (CassandraRow row : rows) { + double ofTrue = evaluator.evaluate(condition, row).getOfTrue(); + if (ofTrue >= 1.0) { + return TRUE_TRUTHNESS; + } else if (ofTrue > maxOfTrue) { + maxOfTrue = ofTrue; + } + } + + return TruthnessUtils.buildScaledTruthness(C, maxOfTrue); + } + + private static List toList(Iterable iterable) { + if (iterable instanceof List) { + return (List) iterable; + } + + List list = new ArrayList<>(); + for (CassandraRow row : iterable) { + list.add(row); + } + return list; + } +} \ No newline at end of file diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/calculator/CassandraOperationEvaluator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/calculator/CassandraOperationEvaluator.java new file mode 100644 index 0000000000..6dfcbad855 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/calculator/CassandraOperationEvaluator.java @@ -0,0 +1,411 @@ +package org.evomaster.client.java.controller.cassandra.calculator; + +import org.evomaster.client.java.controller.cassandra.model.CassandraRow; +import org.evomaster.client.java.controller.cassandra.model.CqlDurationLiteral; +import org.evomaster.client.java.controller.cassandra.operations.*; +import org.evomaster.client.java.controller.cassandra.parser.CqlDurationLiteralParser; +import org.evomaster.client.java.distance.heuristics.Truthness; +import org.evomaster.client.java.distance.heuristics.TruthnessUtils; + +import java.net.InetAddress; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalAmount; +import java.time.temporal.TemporalUnit; +import java.util.*; +import java.util.function.Function; + +import static org.evomaster.client.java.controller.cassandra.calculator.CassandraHeuristicsCalculator.*; + +/** + * Computes CQL-heuristics {@link Truthness} for a {@link CqlQueryOperation} against a + * candidate {@link CassandraRow}, i.e. how close the row comes to satisfying the query's + * WHERE-clause condition(s). Used to guide the search when EvoMaster generates data intended + * to make a given Cassandra query return non-empty results. + */ +public class CassandraOperationEvaluator { + + private enum ComparisonType { EQUALS, GT, GTE, LT, LTE } + + /** + * Each entry attempts to parse a timestamp string under a different format, returning null + * if that format doesn't match, so {@link #parseTimestampString} can fall through to the next one. + */ + private static final List> TIMESTAMP_PARSERS = Arrays.asList( + CassandraOperationEvaluator::tryParseIsoInstant, + CassandraOperationEvaluator::tryParseWithTimestampFormatters, + CassandraOperationEvaluator::tryParseDateWithOffset, + CassandraOperationEvaluator::tryParseDateOnly + ); + + private static final DateTimeFormatter[] TIMESTAMP_FORMATTERS = { + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSXX"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssXX"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mmXX"), + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXX"), + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXX"), + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mmXX"), + }; + + private static final DateTimeFormatter DATE_WITH_OFFSET = DateTimeFormatter.ofPattern("yyyy-MM-ddXX"); + + /** + * Recursively evaluates a {@link CqlQueryOperation} against a row, dispatching to the + * appropriate comparison logic based on the operation's concrete type (AND, equals, + * greater/less-than variants, IN, CONTAINS, CONTAINS KEY). Returns {@code FALSE_TRUTHNESS} + * for operation types that are not recognised/supported. + */ + public Truthness evaluate(CqlQueryOperation op, CassandraRow candidateRow) { + if (op instanceof AndOperation) { + return evaluateAnd((AndOperation) op, candidateRow); + } else if (op instanceof EqualsOperation) { + return evaluateEquals((EqualsOperation) op, candidateRow); + } else if (op instanceof GreaterThanOperation) { + return evaluateGreaterThan((GreaterThanOperation) op, candidateRow); + } else if (op instanceof GreaterThanEqualsOperation) { + return evaluateGreaterThanEquals((GreaterThanEqualsOperation) op, candidateRow); + } else if (op instanceof LessThanOperation) { + return evaluateLessThan((LessThanOperation) op, candidateRow); + } else if (op instanceof LessThanEqualsOperation) { + return evaluateLessThanEquals((LessThanEqualsOperation) op, candidateRow); + } else if (op instanceof InOperation) { + return evaluateIn((InOperation) op, candidateRow); + } else if (op instanceof ContainsOperation) { + return evaluateContains((ContainsOperation) op, candidateRow); + } else if (op instanceof ContainsKeyOperation) { + return evaluateContainsKey((ContainsKeyOperation) op, candidateRow); + } else { + return FALSE_TRUTHNESS; + } + } + + private Truthness evaluateAnd(AndOperation op, CassandraRow candidateRow) { + List results = new ArrayList<>(); + for (CqlQueryOperation condition : op.getConditions()) { + results.add(evaluate(condition, candidateRow)); + } + + return TruthnessUtils.buildAndAggregationTruthness(results.toArray(new Truthness[0])); + } + + private Truthness evaluateEquals(EqualsOperation op, CassandraRow candidateRow) { + return evaluateComparison(op, candidateRow, ComparisonType.EQUALS); + } + + private Truthness evaluateGreaterThan(GreaterThanOperation op, CassandraRow candidateRow) { + return evaluateComparison(op, candidateRow, ComparisonType.GT); + } + + private Truthness evaluateGreaterThanEquals(GreaterThanEqualsOperation op, CassandraRow candidateRow) { + return evaluateComparison(op, candidateRow, ComparisonType.GTE); + } + + private Truthness evaluateLessThan(LessThanOperation op, CassandraRow candidateRow) { + return evaluateComparison(op, candidateRow, ComparisonType.LT); + } + + private Truthness evaluateLessThanEquals(LessThanEqualsOperation op, CassandraRow candidateRow) { + return evaluateComparison(op, candidateRow, ComparisonType.LTE); + } + + private Truthness evaluateIn(InOperation op, CassandraRow candidateRow) { + Object rowValue = candidateRow.getValue(op.getColumnName()); + + return any(rowValue, op.getValues()); + } + + private Truthness evaluateContains(ContainsOperation op, CassandraRow candidateRow) { + Object rawCol = candidateRow.getValue(op.getColumnName()); + if (rawCol == null) { + return FALSE_TRUTHNESS; + } else { + return any(op.getValue(), toElementList(rawCol)); + } + } + + private Truthness evaluateContainsKey(ContainsKeyOperation op, CassandraRow candidateRow) { + Object rawCol = candidateRow.getValue(op.getColumnName()); + if (rawCol == null) { + return FALSE_TRUTHNESS; + } else if (!(rawCol instanceof Map)) { + return FALSE_TRUTHNESS; + } else { + List keys = new ArrayList<>(((Map) rawCol).keySet()); + return any(op.getValue(), keys); + } + } + + private Truthness evaluateComparison(ComparisonOperation op, CassandraRow candidateRow, ComparisonType type) { + Object rowValue = candidateRow.getValue(op.getColumnName()); + Object queryValue = op.getValue(); + + if (rowValue == null && queryValue == null) { + return FALSE_TRUTHNESS; + } else if (rowValue == null || queryValue == null) { + return FALSE_TRUTHNESS_BETTER; + } else { + Truthness typeResult = compareByType(rowValue, queryValue, type); + if (typeResult.isTrue()) { + return typeResult; + } else { + return TruthnessUtils.buildScaledTruthness(C_BETTER, typeResult.getOfTrue()); + } + } + } + + private Truthness compareByType(Object rowValue, Object literalValue, ComparisonType comparisonType) { + if (rowValue instanceof Number && literalValue instanceof Number) { + return compareNumeric(rowValue, literalValue, comparisonType); + } else if (rowValue instanceof String || rowValue instanceof InetAddress) { + return compareString(rowValue, literalValue, comparisonType); + } else if (rowValue instanceof Boolean) { + return compareBoolean(rowValue, literalValue, comparisonType); + } else if (rowValue instanceof UUID) { + return compareUuid(rowValue, literalValue, comparisonType); + } else if (isTemporalType(rowValue)) { + return compareTemporal(rowValue, literalValue, comparisonType); + } else if (isCqlDuration(rowValue)) { + return compareDuration(rowValue, literalValue, comparisonType); + } else { + throw new IllegalArgumentException("Unsupported row value type: " + rowValue.getClass().getName()); + } + } + + private Truthness compareNumeric(Object rowValue, Object literalValue, ComparisonType comparisonType) { + double x = ((Number) rowValue).doubleValue(); + double y = ((Number) literalValue).doubleValue(); + + return getTruthnessForNumeric(comparisonType, x, y); + } + + private Truthness compareString(Object rowValue, Object literalValue, ComparisonType comparisonType) { + if (comparisonType != ComparisonType.EQUALS) { + throw new IllegalArgumentException("Unsupported operator for string literals: " + comparisonType); + } else { + String a = (rowValue instanceof InetAddress) + ? ((InetAddress) rowValue).getHostAddress() + : (String) rowValue; + String b = (String) literalValue; + + return TruthnessUtils.getStringEqualityTruthness(a, b); + } + } + + private Truthness compareBoolean(Object rowValue, Object literalValue, ComparisonType comparisonType) { + if (comparisonType != ComparisonType.EQUALS) { + throw new IllegalArgumentException("Unsupported operator for boolean literals: " + comparisonType); + } else { + double x = ((Boolean) rowValue) ? 1.0 : 0.0; + double y = ((Boolean) literalValue) ? 1.0 : 0.0; + + return TruthnessUtils.getEqualityTruthness(x, y); + } + } + + private Truthness compareUuid(Object rowValue, Object literalValue, ComparisonType comparisonType) { + if (comparisonType != ComparisonType.EQUALS) { + throw new IllegalArgumentException("Unsupported operator for UUID literals: " + comparisonType); + } else { + return TruthnessUtils.getEqualityTruthness((UUID) rowValue, (UUID) literalValue); + } + } + + private static boolean isTemporalType(Object rowValue) { + return rowValue instanceof LocalDate + || rowValue instanceof LocalTime + || rowValue instanceof Instant; + } + + private Truthness compareTemporal(Object rowValue, Object literalValue, ComparisonType comparisonType) { + try { + double x = (double) toLong(rowValue, rowValue); + double y = (double) toLong(literalValue, rowValue); + + return getTruthnessForNumeric(comparisonType, x, y); + } catch (Exception e) { + return FALSE_TRUTHNESS; + } + } + + private Truthness getTruthnessForNumeric(ComparisonType comparisonType, double x, double y) { + switch (comparisonType) { + case EQUALS: return TruthnessUtils.getEqualityTruthness(x, y); + case GT: return TruthnessUtils.getLessThanTruthness(y, x); + case GTE: return TruthnessUtils.getLessThanTruthness(x, y).invert(); + case LT: return TruthnessUtils.getLessThanTruthness(x, y); + case LTE: return TruthnessUtils.getLessThanTruthness(y, x).invert(); + default: throw new IllegalArgumentException("Unsupported operator for numeric literals: " + comparisonType); + } + } + + /** + * Converts a temporal value to a comparable epoch-millisecond {@code long}. + *

+ * {@code rowValue} is the value to convert, and it may come either from the row itself + * or from the query's literal — in the latter case it is often a plain {@link String} + * (e.g. {@code "2011-02-03"}) that carries no type information of its own. {@code + * rowValueHint} disambiguates which temporal type ({@link LocalDate}, {@link LocalTime} + * or {@link Instant}) {@code rowValue} should be interpreted as, since that type is only + * known from the actual row value's column type. This lets a query literal be parsed and + * compared using the same temporal interpretation as the row value it's being compared + * against, even though the literal itself is untyped. + */ + private static long toLong(Object rowValue, Object rowValueHint) { + if (rowValueHint instanceof LocalDate) { + LocalDate dateVal = (rowValue instanceof Long) ? LocalDate.ofEpochDay((Long) rowValue) + : (rowValue instanceof String) ? LocalDate.parse((String) rowValue) + : (LocalDate) rowValue; + + return dateVal.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(); + } else if (rowValueHint instanceof LocalTime) { + LocalTime timeVal = (rowValue instanceof Long) ? LocalTime.ofNanoOfDay((Long) rowValue) + : (rowValue instanceof String) ? LocalTime.parse((String) rowValue) + : (LocalTime) rowValue; + + return LocalDateTime.of(LocalDate.of(1970, 1, 1), timeVal).toInstant(ZoneOffset.UTC).toEpochMilli(); + } else if (rowValueHint instanceof Instant) { + if (rowValue instanceof Long) { + return (Long) rowValue; + } else if (rowValue instanceof Instant) { + return ((Instant) rowValue).toEpochMilli(); + } else if (rowValue instanceof String) { + return parseTimestampString((String) rowValue).toEpochMilli(); + } else { + throw new IllegalArgumentException("Unexpected timestamp value type: " + rowValue.getClass()); + } + } else { + throw new IllegalArgumentException("Unrecognized temporal type: " + rowValueHint.getClass()); + } + } + + private static Instant parseTimestampString(String rowValue) { + for (Function parser : TIMESTAMP_PARSERS) { + Instant parsed = parser.apply(rowValue); + if (parsed != null) { + return parsed; + } + } + + throw new IllegalArgumentException("Cannot parse timestamp string: " + rowValue); + } + + private static Instant tryParseIsoInstant(String rowValue) { + try { + return Instant.parse(rowValue); + } catch (DateTimeParseException e) { + return null; + } + } + + private static Instant tryParseWithTimestampFormatters(String rowValue) { + for (DateTimeFormatter formatter : TIMESTAMP_FORMATTERS) { + try { + return OffsetDateTime.parse(rowValue, formatter).toInstant(); + } catch (DateTimeParseException ignored) {} + } + return null; + } + + /** + * Parses date-only strings with an offset (e.g. {@code "2011-02-03+0000"}), which {@link + * OffsetDateTime#parse} rejects since they carry no time component. The {@link LocalDate} + * and {@link ZoneOffset} are extracted from the parsed {@link TemporalAccessor} directly. + */ + private static Instant tryParseDateWithOffset(String rowValue) { + try { + TemporalAccessor accessor = DATE_WITH_OFFSET.parse(rowValue); + return LocalDate.from(accessor).atStartOfDay(ZoneOffset.from(accessor)).toInstant(); + } catch (DateTimeParseException e) { + return null; + } + } + + /** + * Parses date-only strings without an offset (e.g. {@code "2011-02-03"}), treating them as + * midnight UTC. + */ + private static Instant tryParseDateOnly(String rowValue) { + try { + return LocalDate.parse(rowValue).atStartOfDay(ZoneOffset.UTC).toInstant(); + } catch (DateTimeParseException e) { + return null; + } + } + + /** + * Identifies the driver's {@code CqlDuration} by checking for its distinctive combination of supported temporal + * units rather than {@code instanceof}, since other JDK types (e.g. {@link Period}) also implement {@link TemporalAmount}. + */ + private static boolean isCqlDuration(Object rowValue) { + if (!(rowValue instanceof TemporalAmount)) { + return false; + } else { + List units = ((TemporalAmount) rowValue).getUnits(); + return units.contains(ChronoUnit.MONTHS) + && units.contains(ChronoUnit.DAYS) + && units.contains(ChronoUnit.NANOS); + } + } + + private Truthness compareDuration(Object rowValue, Object literalValue, ComparisonType comparisonType) { + if (comparisonType != ComparisonType.EQUALS) { + throw new IllegalArgumentException("Unsupported operator for duration literals: " + comparisonType); + } else { + TemporalAmount duration = (TemporalAmount) rowValue; + long months = duration.get(ChronoUnit.MONTHS); + long days = duration.get(ChronoUnit.DAYS); + long nanos = duration.get(ChronoUnit.NANOS); + + CqlDurationLiteral literalDurationValue = CqlDurationLiteralParser.parse((String) literalValue); + + return TruthnessUtils.buildAndAggregationTruthness( + TruthnessUtils.getEqualityTruthness(months, literalDurationValue.months), + TruthnessUtils.getEqualityTruthness(days, literalDurationValue.days), + TruthnessUtils.getEqualityTruthness(nanos, literalDurationValue.nanos) + ); + } + } + + private Truthness any(Object value, List candidates) { + if (candidates.isEmpty()) { + return FALSE_TRUTHNESS; + } else { + Truthness[] truthnesses = candidates.stream() + .map(candidate -> evaluateEquals(value, candidate)) + .toArray(Truthness[]::new); + + return TruthnessUtils.buildOrAggregationTruthness(truthnesses); + } + } + + private Truthness evaluateEquals(Object a, Object b) { + if (a == null && b == null) { + return FALSE_TRUTHNESS; + } else if (a == null || b == null) { + return FALSE_TRUTHNESS_BETTER; + } else { + Truthness raw = compareByType(a, b, ComparisonType.EQUALS); + if (raw.isTrue()) { + return raw; + } else { + return TruthnessUtils.buildScaledTruthness(C_BETTER, raw.getOfTrue()); + } + } + } + + private static List toElementList(Object collection) { + if (collection instanceof List) { + return (List) collection; + } else if (collection instanceof Set) { + return new ArrayList<>((Set) collection); + } else if (collection instanceof Map) { + return new ArrayList<>(((Map) collection).values()); + } else { + return Collections.emptyList(); + } + } + +} \ No newline at end of file diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/model/CassandraRow.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/model/CassandraRow.java new file mode 100644 index 0000000000..9746dbcf65 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/model/CassandraRow.java @@ -0,0 +1,48 @@ +package org.evomaster.client.java.controller.cassandra.model; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A single row of a Cassandra table, keyed by column name. + * Column-name lookups are case-insensitive and tolerate CQL-quoted identifiers + * (e.g. {@code "myCol"}), matching CQL's own identifier normalisation rules. + */ +public class CassandraRow { + + private static final String DOUBLE_QUOTE = "\""; + + private final Map columns; + + public CassandraRow(Map columns) { + this.columns = new LinkedHashMap<>(); + for (Map.Entry e : columns.entrySet()) { + this.columns.put(normaliseColumnName(e.getKey()), e.getValue()); + } + } + + /** + * @param rawColumnName the column name, possibly CQL-quoted + * @return the column's value, or {@code null} if the column is absent from this + * row or its value is {@code null} + */ + public Object getValue(String rawColumnName) { + return columns.get(normaliseColumnName(rawColumnName)); + } + + /** + * Normalises a column name for lookup: strips surrounding double quotes (CQL's + * case-sensitive quoted-identifier syntax) and lower-cases the result, since + * unquoted CQL identifiers are case-insensitive. + * + * @param rawColumnName the column name as it appears in the CQL query, possibly quoted + * @return the normalized column name, or {@code null} if {@code rawColumnName} is {@code null} + */ + private static String normaliseColumnName(String rawColumnName) { + if (rawColumnName == null) return null; + if (rawColumnName.startsWith(DOUBLE_QUOTE) && rawColumnName.endsWith(DOUBLE_QUOTE)) { + return rawColumnName.substring(1, rawColumnName.length() - 1).toLowerCase(); + } + return rawColumnName.toLowerCase(); + } +} \ No newline at end of file diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/model/CqlDurationLiteral.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/model/CqlDurationLiteral.java new file mode 100644 index 0000000000..4ae872a631 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/model/CqlDurationLiteral.java @@ -0,0 +1,21 @@ +package org.evomaster.client.java.controller.cassandra.model; + +/** + * Represents a parsed Cassandra {@code duration} literal, decomposed into its three + * components: months, days, and nanoseconds. + * + * Instances are produced by + * {@link org.evomaster.client.java.controller.cassandra.parser.CqlDurationLiteralParser}. + */ +public class CqlDurationLiteral { + + public final int months; + public final int days; + public final long nanos; + + public CqlDurationLiteral(int months, int days, long nanos) { + this.months = months; + this.days = days; + this.nanos = nanos; + } +} \ No newline at end of file diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/EqualsOperation.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/EqualsOperation.java index e4259af623..e5eb6af21f 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/EqualsOperation.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/EqualsOperation.java @@ -1,5 +1,8 @@ package org.evomaster.client.java.controller.cassandra.operations; +/** + * Represents a CQL equality comparison operation ({@code column = value}). + */ public class EqualsOperation extends ComparisonOperation { public EqualsOperation(String columnName, V value) { super(columnName, value); diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/GreaterThanEqualsOperation.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/GreaterThanEqualsOperation.java index 93e3e0f29b..70f2fe4a0d 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/GreaterThanEqualsOperation.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/GreaterThanEqualsOperation.java @@ -1,5 +1,8 @@ package org.evomaster.client.java.controller.cassandra.operations; +/** + * Represents a CQL greater-than-or-equals comparison operation ({@code column >= value}). + */ public class GreaterThanEqualsOperation extends ComparisonOperation { public GreaterThanEqualsOperation(String columnName, V value) { super(columnName, value); diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/GreaterThanOperation.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/GreaterThanOperation.java index b98de80818..03bb7dddd4 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/GreaterThanOperation.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/GreaterThanOperation.java @@ -1,5 +1,8 @@ package org.evomaster.client.java.controller.cassandra.operations; +/** + * Represents a CQL greater-than comparison operation ({@code column > value}). + */ public class GreaterThanOperation extends ComparisonOperation { public GreaterThanOperation(String columnName, V value) { super(columnName, value); diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/LessThanEqualsOperation.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/LessThanEqualsOperation.java index e4f0a2860a..60d621106e 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/LessThanEqualsOperation.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/LessThanEqualsOperation.java @@ -1,5 +1,8 @@ package org.evomaster.client.java.controller.cassandra.operations; +/** + * Represents a CQL less-than-or-equals comparison operation ({@code column <= value}). + */ public class LessThanEqualsOperation extends ComparisonOperation { public LessThanEqualsOperation(String columnName, V value) { super(columnName, value); diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/LessThanOperation.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/LessThanOperation.java index 00b1c7a32e..bfd88be50c 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/LessThanOperation.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/operations/LessThanOperation.java @@ -1,5 +1,8 @@ package org.evomaster.client.java.controller.cassandra.operations; +/** + * Represents a CQL less-than comparison operation ({@code column < value}). + */ public class LessThanOperation extends ComparisonOperation { public LessThanOperation(String columnName, V value) { super(columnName, value); diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/parser/CqlDurationLiteralParser.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/parser/CqlDurationLiteralParser.java new file mode 100644 index 0000000000..11ddce2c76 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/parser/CqlDurationLiteralParser.java @@ -0,0 +1,242 @@ +package org.evomaster.client.java.controller.cassandra.parser; + +import org.evomaster.client.java.controller.cassandra.model.CqlDurationLiteral; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses a Cassandra duration string literal into a {@link CqlDurationLiteral}. + * + *

Supports four formats, all optionally prefixed with {@code -} for a negative duration, + * mirroring {@code com.datastax.oss.driver.api.core.data.CqlDuration#from(String)}: + * + *

+ */ +public class CqlDurationLiteralParser { + + private CqlDurationLiteralParser() {} + + private static final String UNIT_MONTH = "mo"; + private static final String UNIT_MILLISECOND = "ms"; + private static final String UNIT_MICROSECOND_MU = "µs"; + private static final String UNIT_MICROSECOND = "us"; + private static final String UNIT_NANOSECOND = "ns"; + private static final String UNIT_YEAR = "y"; + private static final String UNIT_WEEK = "w"; + private static final String UNIT_DAY = "d"; + private static final String UNIT_HOUR = "h"; + private static final String UNIT_MINUTE = "m"; + private static final String UNIT_SECOND = "s"; + + private static final int MONTHS_PER_YEAR = 12; + private static final int DAYS_PER_WEEK = 7; + private static final long NANOS_PER_HOUR = 3_600_000_000_000L; + private static final long NANOS_PER_MINUTE = 60_000_000_000L; + private static final long NANOS_PER_SECOND = 1_000_000_000L; + private static final long NANOS_PER_MILLISECOND = 1_000_000L; + private static final long NANOS_PER_MICROSECOND = 1_000L; + + private static final char ISO_TIME_SEPARATOR = 'T'; + private static final char ISO_YEAR_DESIGNATOR = 'Y'; + private static final char ISO_MONTH_OR_MINUTE_DESIGNATOR = 'M'; + private static final char ISO_DAY_DESIGNATOR = 'D'; + private static final char ISO_HOUR_DESIGNATOR = 'H'; + private static final char ISO_SECOND_DESIGNATOR = 'S'; + + private static final String ISO_DURATION_PREFIX_UPPER = "P"; + private static final String ISO_DURATION_PREFIX_LOWER = "p"; + private static final String ISO_WEEK_SUFFIX = "W"; + + /** + * Matches the standard Cassandra format: an optional digit sequence followed by a unit + * token. Units are ordered longest-first ({@code mo, ms, µs, us, ns, y, w, d, h, m, s}) to + * avoid {@code m} consuming {@code mo}, or {@code ms} consuming {@code m}. + */ + private static final Pattern STANDARD_PATTERN = Pattern.compile( + "(\\d+)(" + String.join("|", UNIT_MONTH, UNIT_MILLISECOND, UNIT_MICROSECOND_MU, + UNIT_MICROSECOND, UNIT_NANOSECOND, UNIT_YEAR, UNIT_WEEK, UNIT_DAY, UNIT_HOUR, UNIT_MINUTE, UNIT_SECOND) + ")", + Pattern.CASE_INSENSITIVE); + + /** + * Matches the general ISO 8601 format, e.g. {@code P1Y2M3DT4H5M6S}. Every component is + * optional, mirroring the driver's own grammar, so bare {@code P} or {@code PT} are valid + * (zero-length) durations. + */ + private static final Pattern ISO8601_PATTERN = Pattern.compile( + ISO_DURATION_PREFIX_UPPER + + "(?:(\\d+)" + ISO_YEAR_DESIGNATOR + ")?" + + "(?:(\\d+)" + ISO_MONTH_OR_MINUTE_DESIGNATOR + ")?" + + "(?:(\\d+)" + ISO_DAY_DESIGNATOR + ")?" + + "(?:" + ISO_TIME_SEPARATOR + + "(?:(\\d+)" + ISO_HOUR_DESIGNATOR + ")?" + + "(?:(\\d+)" + ISO_MONTH_OR_MINUTE_DESIGNATOR + ")?" + + "(?:(\\d+)" + ISO_SECOND_DESIGNATOR + ")?)?"); + + /** Matches the ISO 8601 weeks-only format, e.g. {@code P3W}. */ + private static final Pattern ISO8601_WEEK_PATTERN = + Pattern.compile(ISO_DURATION_PREFIX_UPPER + "(\\d+)" + ISO_WEEK_SUFFIX); + + /** Matches the ISO 8601 alternative format, e.g. {@code P0001-02-03T04:05:06}. */ + private static final Pattern ISO8601_ALTERNATIVE_PATTERN = Pattern.compile( + ISO_DURATION_PREFIX_UPPER + "(\\d{4})-(\\d{2})-(\\d{2})" + ISO_TIME_SEPARATOR + "(\\d{2}):(\\d{2}):(\\d{2})"); + + /** + * Parses a CQL duration literal into a {@link CqlDurationLiteral}. A leading {@code -} is + * stripped and applied as a sign to all three components. The remainder is dispatched to the + * matching pattern-specific parser by {@link #parseUnsigned}. + * + * @param stringDuration the duration literal to parse; must not be {@code null}, empty, or + * blank + * @return the parsed duration, decomposed into months, days and nanoseconds + * @throws IllegalArgumentException if {@code stringDuration} is {@code null}, empty, or blank + */ + public static CqlDurationLiteral parse(String stringDuration) { + if (stringDuration == null) { + throw new IllegalArgumentException("Empty duration literal"); + } else { + String t = stringDuration.trim(); + if (t.isEmpty()) { + throw new IllegalArgumentException("Empty duration literal"); + } else { + boolean isNegative = t.startsWith("-"); + String unsigned = isNegative ? t.substring(1) : t; + if (unsigned.isEmpty()) { + throw new IllegalArgumentException("Empty duration literal"); + } else { + CqlDurationLiteral magnitude = parseUnsigned(unsigned); + + return isNegative + ? new CqlDurationLiteral(-magnitude.months, -magnitude.days, -magnitude.nanos) + : magnitude; + } + } + } + } + + /** + * Routes to one of the four pattern-specific parsers, mirroring the format detection in the + * driver's {@code CqlDuration.from(String)}: non-{@code P}-prefixed input is the standard + * Cassandra format; {@code P}-prefixed input ending in {@code W} is the ISO week-only format; + * {@code P}-prefixed input containing {@code -} is the ISO alternative format; anything else + * {@code P}-prefixed is the general ISO 8601 format. + */ + private static CqlDurationLiteral parseUnsigned(String unsigned) { + if (!unsigned.startsWith(ISO_DURATION_PREFIX_UPPER) && !unsigned.startsWith(ISO_DURATION_PREFIX_LOWER)) { + return parseStandardPattern(unsigned); + } else { + String upper = unsigned.toUpperCase(); + if (upper.endsWith(ISO_WEEK_SUFFIX)) { + return parseIso8601WeekPattern(upper); + } else if (upper.contains("-")) { + return parseIso8601AlternativePattern(upper); + } else { + return parseIso8601Pattern(upper); + } + } + } + + private static CqlDurationLiteral parseStandardPattern(String text) { + int months = 0; + int days = 0; + long nanos = 0L; + + Matcher m = STANDARD_PATTERN.matcher(text); + while (m.find()) { + long value = Long.parseLong(m.group(1)); + String unit = m.group(2).toLowerCase(); + switch (unit) { + case UNIT_YEAR: + months += (int) (value * MONTHS_PER_YEAR); + break; + case UNIT_MONTH: + months += (int) value; + break; + case UNIT_WEEK: + days += (int) (value * DAYS_PER_WEEK); + break; + case UNIT_DAY: + days += (int) value; + break; + case UNIT_HOUR: + nanos += value * NANOS_PER_HOUR; + break; + case UNIT_MINUTE: + nanos += value * NANOS_PER_MINUTE; + break; + case UNIT_SECOND: + nanos += value * NANOS_PER_SECOND; + break; + case UNIT_MILLISECOND: + nanos += value * NANOS_PER_MILLISECOND; + break; + case UNIT_MICROSECOND: + case UNIT_MICROSECOND_MU: + nanos += value * NANOS_PER_MICROSECOND; + break; + case UNIT_NANOSECOND: + nanos += value; + break; + default: + break; + } + } + return new CqlDurationLiteral(months, days, nanos); + } + + private static CqlDurationLiteral parseIso8601Pattern(String isoDuration) { + Matcher matcher = ISO8601_PATTERN.matcher(isoDuration); + if (!matcher.matches()) { + throw new IllegalArgumentException("Unable to parse duration literal '" + isoDuration + "'"); + } else { + int years = groupAsInt(matcher, 1); + int months = groupAsInt(matcher, 2); + int days = groupAsInt(matcher, 3); + int hours = groupAsInt(matcher, 4); + int minutes = groupAsInt(matcher, 5); + int seconds = groupAsInt(matcher, 6); + + long nanos = hours * NANOS_PER_HOUR + minutes * NANOS_PER_MINUTE + seconds * NANOS_PER_SECOND; + return new CqlDurationLiteral(years * MONTHS_PER_YEAR + months, days, nanos); + } + } + + /** Returns the matched group as an {@code int}, or {@code 0} if the (optional) group didn't participate. */ + private static int groupAsInt(Matcher matcher, int group) { + String value = matcher.group(group); + return value == null ? 0 : Integer.parseInt(value); + } + + private static CqlDurationLiteral parseIso8601WeekPattern(String isoDuration) { + Matcher matcher = ISO8601_WEEK_PATTERN.matcher(isoDuration); + if (!matcher.matches()) { + throw new IllegalArgumentException("Unable to parse duration literal '" + isoDuration + "'"); + } else { + int weeks = Integer.parseInt(matcher.group(1)); + return new CqlDurationLiteral(0, weeks * DAYS_PER_WEEK, 0L); + } + } + + private static CqlDurationLiteral parseIso8601AlternativePattern(String isoDuration) { + Matcher matcher = ISO8601_ALTERNATIVE_PATTERN.matcher(isoDuration); + if (!matcher.matches()) { + throw new IllegalArgumentException("Unable to parse duration literal '" + isoDuration + "'"); + } else { + int years = Integer.parseInt(matcher.group(1)); + int months = Integer.parseInt(matcher.group(2)); + int days = Integer.parseInt(matcher.group(3)); + int hours = Integer.parseInt(matcher.group(4)); + int minutes = Integer.parseInt(matcher.group(5)); + int seconds = Integer.parseInt(matcher.group(6)); + + long nanos = hours * NANOS_PER_HOUR + minutes * NANOS_PER_MINUTE + seconds * NANOS_PER_SECOND; + return new CqlDurationLiteral(years * MONTHS_PER_YEAR + months, days, nanos); + } + } +} \ No newline at end of file diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/parser/CqlParserUtils.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/parser/CqlParserUtils.java index 741a4e110d..2636e454e3 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/parser/CqlParserUtils.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/cassandra/parser/CqlParserUtils.java @@ -8,16 +8,39 @@ import java.util.List; import java.util.UUID; +/** + * Utility methods to parse CQL (Cassandra Query Language) commands with the ANTLR-generated + * {@link CqlParser}, and to extract the information needed for heuristics computation (e.g. + * the WHERE-clause condition tree) from the resulting parse tree. + */ public class CqlParserUtils { private CqlParserUtils() {} + private static final String KEYWORD_SELECT = "SELECT"; + private static final String KEYWORD_UPDATE = "UPDATE"; + private static final String KEYWORD_DELETE = "DELETE"; + + /** + * Parses a CQL command string into its ANTLR parse tree, with no validity checks + * performed on the result (assumes {@link #canParseCqlCommand} was used first). + * + * @param cqlCommand the CQL command to parse + * @return the root of the resulting ANTLR parse tree + */ public static CqlParser.RootContext parseCqlCommand(String cqlCommand) { CqlLexer lexer = new CqlLexer(CharStreams.fromString(cqlCommand)); CqlParser parser = new CqlParser(new CommonTokenStream(lexer)); return parser.root(); } + /** + * Checks whether {@code cqlCommand} can be parsed into a single, well-formed CQL statement. + * + * @param cqlCommand the CQL command to validate + * @return {@code true} if the command parses cleanly (no parser exception, and exactly one + * statement is present); {@code false} otherwise + */ public static boolean canParseCqlCommand(String cqlCommand) { try { CqlParser.RootContext root = parseCqlCommand(cqlCommand); @@ -27,25 +50,51 @@ public static boolean canParseCqlCommand(String cqlCommand) { } } + /** + * @param cqlCommand the CQL command to inspect + * @return {@code true} if {@code cqlCommand} is a SELECT statement + */ public static boolean isSelect(String cqlCommand) { - return cqlCommand.trim().toUpperCase().startsWith("SELECT"); + return cqlCommand.trim().toUpperCase().startsWith(KEYWORD_SELECT); } + /** + * @param cqlCommand the CQL command to inspect + * @return {@code true} if {@code cqlCommand} is an UPDATE statement + */ public static boolean isUpdate(String cqlCommand) { - return cqlCommand.trim().toUpperCase().startsWith("UPDATE"); + return cqlCommand.trim().toUpperCase().startsWith(KEYWORD_UPDATE); } + /** + * @param cqlCommand the CQL command to inspect + * @return {@code true} if {@code cqlCommand} is a DELETE statement + */ public static boolean isDelete(String cqlCommand) { - return cqlCommand.trim().toUpperCase().startsWith("DELETE"); + return cqlCommand.trim().toUpperCase().startsWith(KEYWORD_DELETE); } + /** + * Extracts the WHERE-clause parse-tree node from a parsed CQL SELECT, UPDATE, or DELETE + * statement. + * + * @param root the root of a parsed CQL command, as returned by {@link #parseCqlCommand} + * @return the WHERE clause's parse-tree node, or {@code null} if the statement has none + * (or isn't a SELECT/UPDATE/DELETE) + */ public static CqlParser.WhereSpecContext getWhereSpec(CqlParser.RootContext root) { CqlParser.CqlContext cql = root.cqls() != null ? root.cqls().cql(0) : null; - if (cql == null) return null; - if (cql.select_() != null) return cql.select_().whereSpec(); - if (cql.update() != null) return cql.update().whereSpec(); - if (cql.delete_() != null) return cql.delete_().whereSpec(); - return null; + if (cql == null) { + return null; + } else if (cql.select_() != null) { + return cql.select_().whereSpec(); + } else if (cql.update() != null) { + return cql.update().whereSpec(); + } else if (cql.delete_() != null) { + return cql.delete_().whereSpec(); + } else { + return null; + } } /** @@ -56,70 +105,85 @@ public static CqlParser.WhereSpecContext getWhereSpec(CqlParser.RootContext root */ public static CqlQueryOperation getWhereOperation(CqlParser.RootContext root) { CqlParser.WhereSpecContext whereSpec = getWhereSpec(root); - if (whereSpec == null) return null; - - List elements = whereSpec.relationElements().relationElement(); - if (elements.isEmpty()) return null; - if (elements.size() == 1) return parseRelationElement(elements.get(0)); - - List ops = new ArrayList<>(); - for (CqlParser.RelationElementContext el : elements) { - CqlQueryOperation op = parseRelationElement(el); - if (op != null) ops.add(op); + if (whereSpec != null) { + List elements = whereSpec.relationElements().relationElement(); + if (elements.isEmpty()) { + return null; + } else if (elements.size() == 1) { + return parseRelationElement(elements.get(0)); + } else { + List ops = new ArrayList<>(); + for (CqlParser.RelationElementContext el : elements) { + CqlQueryOperation op = parseRelationElement(el); + if (op != null) { + ops.add(op); + } + } + return new AndOperation(ops); + } + } else { + return null; } - return new AndOperation(ops); } private static CqlQueryOperation parseRelationElement(CqlParser.RelationElementContext rel) { - // CONTAINS KEY: col CONTAINS KEY value - if (rel.relalationContainsKey() != null) { + if (rel.relalationContainsKey() != null) { // CONTAINS KEY CqlParser.RelalationContainsKeyContext ck = rel.relalationContainsKey(); return new ContainsKeyOperation<>(ck.OBJECT_NAME().getText(), parseConstant(ck.constant())); - } - - // CONTAINS: col CONTAINS value - if (rel.relalationContains() != null) { + } else if (rel.relalationContains() != null) { // CONTAINS CqlParser.RelalationContainsContext c = rel.relalationContains(); return new ContainsOperation<>(c.OBJECT_NAME().getText(), parseConstant(c.constant())); - } - - // IN: col IN (v1, v2, ...) - if (rel.kwIn() != null) { + } else if (rel.kwIn() != null) { // IN String col = rel.OBJECT_NAME(0).getText(); List values = new ArrayList<>(); + if (rel.functionArgs() != null) { for (CqlParser.ConstantContext cc : rel.functionArgs().constant()) { values.add(parseConstant(cc)); } } return new InOperation(col, values); + } else { + // Comparison: col OP constant + CqlParser.ConstantContext constant = rel.constant(); + if (constant != null && rel.OBJECT_NAME(0) != null) { + String col = rel.OBJECT_NAME(0).getText(); + Object value = parseConstant(constant); + if (rel.OPERATOR_EQ() != null) { + return new EqualsOperation<>(col, value); + } else if (rel.OPERATOR_GT() != null) { + return new GreaterThanOperation<>(col, value); + } else if (rel.OPERATOR_GTE() != null) { + return new GreaterThanEqualsOperation<>(col, value); + } else if (rel.OPERATOR_LT() != null) { + return new LessThanOperation<>(col, value); + } else if (rel.OPERATOR_LTE() != null) { + return new LessThanEqualsOperation<>(col, value); + } else { + return null; + } + } else { + return null; + } } - - // Comparison: col OP constant - CqlParser.ConstantContext constant = rel.constant(); - if (constant != null && rel.OBJECT_NAME(0) != null) { - String col = rel.OBJECT_NAME(0).getText(); - Object value = parseConstant(constant); - if (rel.OPERATOR_EQ() != null) return new EqualsOperation<>(col, value); - if (rel.OPERATOR_GT() != null) return new GreaterThanOperation<>(col, value); - if (rel.OPERATOR_GTE() != null) return new GreaterThanEqualsOperation<>(col, value); - if (rel.OPERATOR_LT() != null) return new LessThanOperation<>(col, value); - if (rel.OPERATOR_LTE() != null) return new LessThanEqualsOperation<>(col, value); - } - - return null; } private static Object parseConstant(CqlParser.ConstantContext ctx) { - if (ctx.UUID() != null) return UUID.fromString(ctx.UUID().getText()); - if (ctx.stringLiteral() != null) { + if (ctx.UUID() != null) { + return UUID.fromString(ctx.UUID().getText()); + } else if (ctx.stringLiteral() != null) { String raw = ctx.stringLiteral().getText(); return raw.substring(1, raw.length() - 1); // strip surrounding single quotes + } else if (ctx.decimalLiteral() != null) { + return Long.parseLong(ctx.decimalLiteral().getText()); + } else if (ctx.floatLiteral() != null) { + return Double.parseDouble(ctx.floatLiteral().getText()); + } else if (ctx.booleanLiteral() != null) { + return ctx.booleanLiteral().getText().equalsIgnoreCase("true"); + } else if (ctx.durationLiteral() != null) { + return ctx.durationLiteral().getText(); + } else { + return null; // kwNull, codeBlock, hexadecimal } - if (ctx.decimalLiteral() != null) return Long.parseLong(ctx.decimalLiteral().getText()); - if (ctx.floatLiteral() != null) return Double.parseDouble(ctx.floatLiteral().getText()); - if (ctx.booleanLiteral() != null) return ctx.booleanLiteral().getText().equalsIgnoreCase("true"); - if (ctx.durationLiteral() != null) return ctx.durationLiteral().getText(); - return null; // kwNull, codeBlock, hexadecimal } } \ No newline at end of file diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/calculator/CassandraHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/calculator/CassandraHeuristicsCalculatorTest.java new file mode 100644 index 0000000000..5e814b6477 --- /dev/null +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/calculator/CassandraHeuristicsCalculatorTest.java @@ -0,0 +1,825 @@ +package org.evomaster.client.java.controller.cassandra.calculator; + +import com.datastax.oss.driver.api.core.data.CqlDuration; +import org.evomaster.client.java.controller.cassandra.model.CassandraRow; +import org.evomaster.client.java.distance.heuristics.DistanceHelper; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CassandraHeuristicsCalculatorTest { + + private final CassandraHeuristicsCalculator calc = new CassandraHeuristicsCalculator(); + + private static CassandraRow row(Object... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) m.put((String) kv[i], kv[i + 1]); + return new CassandraRow(m); + } + + private double dist(String cql, CassandraRow... rows) { + return calc.computeDistance(cql, Arrays.asList(rows)); + } + + private double distNoRows(String cql) { + return calc.computeDistance(cql, Collections.emptyList()); + } + + private static final double DELTA = 1e-9; + + @Test + void noWhere_emptyTable_maxDistance() { + // H-Table(0 rows) = FALSE_TRUTHNESS → ofTrue = C → distance = 1 - C + assertEquals(1.0 - DistanceHelper.H_NOT_NULL, distNoRows("SELECT * FROM t"), DELTA); + } + + @Test + void noWhere_nonEmptyTable_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t", row("id", 1L)), DELTA); + } + + @Test + void noWhere_multipleRows_zeroDistance() { + assertEquals(0.0, + dist("SELECT * FROM t", row("id", 1L), row("id", 2L)), + DELTA); + } + + @Test + void where_emptyTable_highDistance() { + double d = distNoRows("SELECT * FROM t WHERE id = 1"); + // andAggregation(FALSE, FALSE) → ofTrue ≈ C → distance ≈ 1 - C + assertTrue(d > 0.5); + } + + // Numeric equality (=) + + @Test + void numericEquals_long_exactMatch() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col = 42", row("col", 42L)), DELTA); + } + + @Test + void numericEquals_integer_exactMatch() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col = 5", row("col", 5)), DELTA); + } + + @Test + void numericEquals_double_exactMatch() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col = 3.14", row("col", 3.14)), DELTA); + } + + @Test + void numericEquals_float_exactMatch() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col = 1", row("col", 1.0f)), DELTA); + } + + @Test + void numericEquals_noMatch_nonZero() { + double d = dist("SELECT * FROM t WHERE col = 42", row("col", 43L)); + assertTrue(d > 0.0 && d < 1.0); + } + + @Test + void numericEquals_closerValueGivesLowerDistance() { + double dClose = dist("SELECT * FROM t WHERE col = 10", row("col", 11L)); + double dFar = dist("SELECT * FROM t WHERE col = 10", row("col", 100L)); + assertTrue(dClose < dFar); + } + + // Numeric ordering (>, >=, <, <=) + + @Test + void numericGT_satisfied_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col > 5", row("col", 10L)), DELTA); + } + + @Test + void numericGT_boundary_notSatisfied() { + assertTrue(dist("SELECT * FROM t WHERE col > 10", row("col", 10L)) > 0.0); + } + + @Test + void numericGT_notSatisfied_nonZero() { + assertTrue(dist("SELECT * FROM t WHERE col > 10", row("col", 5L)) > 0.0); + } + + @Test + void numericGTE_boundary_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col >= 10", row("col", 10L)), DELTA); + } + + @Test + void numericGTE_notSatisfied_nonZero() { + assertTrue(dist("SELECT * FROM t WHERE col >= 10", row("col", 9L)) > 0.0); + } + + @Test + void numericLT_satisfied_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col < 10", row("col", 5L)), DELTA); + } + + @Test + void numericLT_boundary_notSatisfied() { + assertTrue(dist("SELECT * FROM t WHERE col < 10", row("col", 10L)) > 0.0); + } + + @Test + void numericLTE_boundary_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col <= 10", row("col", 10L)), DELTA); + } + + @Test + void numericLTE_notSatisfied_nonZero() { + assertTrue(dist("SELECT * FROM t WHERE col <= 10", row("col", 11L)) > 0.0); + } + + @Test + void numericOrdering_closerToThresholdGivesBetterScore() { + // Col must be > 100; row with 99 is closer than row with 50 + double dClose = dist("SELECT * FROM t WHERE col > 100", row("col", 99L)); + double dFar = dist("SELECT * FROM t WHERE col > 100", row("col", 50L)); + assertTrue(dClose < dFar); + } + + // String equality + + @Test + void stringEquals_exactMatch_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE s = 'hello'", row("s", "hello")), DELTA); + } + + @Test + void stringEquals_differentString_nonZero() { + double d = dist("SELECT * FROM t WHERE s = 'hello'", row("s", "world")); + assertTrue(d > 0.0 && d < 1.0); + } + + @Test + void stringEquals_prefixCloserThanUnrelated() { + double dClose = dist("SELECT * FROM t WHERE s = 'hello'", row("s", "hell")); + double dFar = dist("SELECT * FROM t WHERE s = 'hello'", row("s", "xyz")); + assertTrue(dClose < dFar); + } + + @Test + void stringEquals_emptyString_exactMatch() { + assertEquals(0.0, dist("SELECT * FROM t WHERE s = ''", row("s", "")), DELTA); + } + + // Boolean equality + + @Test + void booleanEquals_trueMatch_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE active = true", row("active", true)), DELTA); + } + + @Test + void booleanEquals_falseMatch_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE active = false", row("active", false)), DELTA); + } + + @Test + void booleanEquals_mismatch_nonZero() { + double d = dist("SELECT * FROM t WHERE active = true", row("active", false)); + assertTrue(d > 0.0 && d < 1.0); + } + + // UUID equality + + private static final UUID UUID_A = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); + private static final UUID UUID_B = UUID.fromString("550e8400-e29b-41d4-a716-446655440001"); + private static final UUID UUID_C = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + @Test + void uuidEquals_sameUuid_zeroDistance() { + assertEquals(0.0, + dist("SELECT * FROM t WHERE id = 550e8400-e29b-41d4-a716-446655440000", + row("id", UUID_A)), + DELTA); + } + + @Test + void uuidEquals_differentUuid_nonZero() { + double d = dist("SELECT * FROM t WHERE id = 550e8400-e29b-41d4-a716-446655440000", + row("id", UUID_B)); + assertTrue(d > 0.0 && d < 1.0); + } + + @Test + void uuidEquals_veryDifferentUuid_higherDistanceThanCloseUuid() { + double dClose = dist("SELECT * FROM t WHERE id = 550e8400-e29b-41d4-a716-446655440000", + row("id", UUID_B)); // differs in one bit of the last byte + double dFar = dist("SELECT * FROM t WHERE id = 550e8400-e29b-41d4-a716-446655440000", + row("id", UUID_C)); // all-zero UUID — many bits differ + assertTrue(dClose < dFar); + } + + // Duration equality (CqlDuration) + + @Test + void durationEquals_exactMatch_zeroDistance() { + // 1y3d4h = months:12, days:3, nanos:4h in nanoseconds + CqlDuration dur = CqlDuration.newInstance(12, 3, 14_400_000_000_000L); + assertEquals(0.0, + dist("SELECT * FROM t WHERE d = 1y3d4h", row("d", dur)), + DELTA); + } + + @Test + void durationEquals_differentMonths_nonZero() { + CqlDuration dur = CqlDuration.newInstance(1, 0, 0); // 1mo + double d = dist("SELECT * FROM t WHERE d = 2mo", row("d", dur)); + assertTrue(d > 0.0 && d < 1.0); + } + + @Test + void durationEquals_allComponentsDiffer_nonZero() { + CqlDuration dur = CqlDuration.newInstance(2, 1, 0); + double d = dist("SELECT * FROM t WHERE d = 1mo3d", row("d", dur)); + assertTrue(d > 0.0 && d < 1.0); + } + + @Test + void durationEquals_closerMonthsGivesBetterScore() { + CqlDuration close = CqlDuration.newInstance(1, 0, 0); // 1 month away from 2mo + CqlDuration far = CqlDuration.newInstance(10, 0, 0); // 8 months away from 2mo + double dClose = dist("SELECT * FROM t WHERE d = 2mo", row("d", close)); + double dFar = dist("SELECT * FROM t WHERE d = 2mo", row("d", far)); + assertTrue(dClose < dFar); + } + + // InetAddress equality + + @Test + void inetEquals_exactMatch_zeroDistance() throws Exception { + InetAddress addr = InetAddress.getByName("192.168.1.1"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ip = '192.168.1.1'", row("ip", addr)), + DELTA); + } + + @Test + void inetEquals_differentAddress_nonZero() throws Exception { + InetAddress addr = InetAddress.getByName("192.168.1.2"); + double d = dist("SELECT * FROM t WHERE ip = '192.168.1.1'", row("ip", addr)); + assertTrue(d > 0.0 && d < 1.0); + } + + @Test + void inetEquals_closerAddressGivesBetterScore() throws Exception { + // '192.168.1.1' vs '192.168.1.2' — one character differs + // '192.168.1.1' vs '10.0.0.1' — many characters differ + double dClose = dist("SELECT * FROM t WHERE ip = '192.168.1.1'", + row("ip", InetAddress.getByName("192.168.1.2"))); + double dFar = dist("SELECT * FROM t WHERE ip = '192.168.1.1'", + row("ip", InetAddress.getByName("10.0.0.1"))); + assertTrue(dClose < dFar); + } + + // AND operator + + @Test + void and_bothSatisfied_zeroDistance() { + assertEquals(0.0, + dist("SELECT * FROM t WHERE a = 1 AND b = 'x'", + row("a", 1L, "b", "x")), + DELTA); + } + + @Test + void and_neitherSatisfied_highDistance() { + double d = dist("SELECT * FROM t WHERE a = 1 AND b = 'x'", + row("a", 999L, "b", "zzz")); + assertTrue(d > 0.0); + } + + @Test + void and_oneSatisfied_betterThanNoneSatisfied() { + double dOne = dist("SELECT * FROM t WHERE a = 1 AND b = 'x'", + row("a", 1L, "b", "zzz")); + double dNone = dist("SELECT * FROM t WHERE a = 1 AND b = 'x'", + row("a", 999L, "b", "zzz")); + assertTrue(dOne < dNone); + } + + @Test + void and_threeConditions_allSatisfied() { + assertEquals(0.0, + dist("SELECT * FROM t WHERE a = 1 AND b = 2 AND c = 3", + row("a", 1L, "b", 2L, "c", 3L)), + DELTA); + } + + // IN operator + + @Test + void in_valueInList_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col IN (1, 2, 3)", row("col", 2L)), DELTA); + } + + @Test + void in_valueFirstInList_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col IN (1, 2, 3)", row("col", 1L)), DELTA); + } + + @Test + void in_valueLastInList_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col IN (1, 2, 3)", row("col", 3L)), DELTA); + } + + @Test + void in_valueNotInList_nonZero() { + assertTrue(dist("SELECT * FROM t WHERE col IN (10, 20)", row("col", 100L)) > 0.0); + } + + @Test + void in_closerCandidateGivesBetterScore() { + double dClose = dist("SELECT * FROM t WHERE col IN (10, 20)", row("col", 11L)); + double dFar = dist("SELECT * FROM t WHERE col IN (10, 20)", row("col", 100L)); + assertTrue(dClose < dFar); + } + + @Test + void in_singleElementList_match_zeroDistance() { + assertEquals(0.0, dist("SELECT * FROM t WHERE col IN (42)", row("col", 42L)), DELTA); + } + + @Test + void in_nullRowValue_nonZeroDistance() { + assertTrue(dist("SELECT * FROM t WHERE col IN (1, 2, 3)", row("col", null)) > 0.0); + } + + @Test + void in_missingColumn_nonZeroDistance() { + assertTrue(dist("SELECT * FROM t WHERE col IN (1, 2, 3)", row("other", 99L)) > 0.0); + } + + // CONTAINS operator + + @Test + void contains_list_valuePresent_zeroDistance() { + assertEquals(0.0, + dist("SELECT * FROM t WHERE tags CONTAINS 2", + row("tags", Arrays.asList(1L, 2L, 3L))), + DELTA); + } + + @Test + void contains_list_valueMissing_nonZero() { + double d = dist("SELECT * FROM t WHERE tags CONTAINS 5", + row("tags", Arrays.asList(10L, 20L))); + assertTrue(d > 0.0); + } + + @Test + void contains_list_closerElementGivesBetterScore() { + double dClose = dist("SELECT * FROM t WHERE tags CONTAINS 10", + row("tags", Arrays.asList(11L))); + double dFar = dist("SELECT * FROM t WHERE tags CONTAINS 10", + row("tags", Arrays.asList(100L))); + assertTrue(dClose < dFar); + } + + @Test + void contains_set_valuePresent_zeroDistance() { + Set s = new LinkedHashSet<>(Arrays.asList(1L, 2L, 3L)); + assertEquals(0.0, dist("SELECT * FROM t WHERE col CONTAINS 2", row("col", s)), DELTA); + } + + @Test + void contains_map_valueInValues_zeroDistance() { + Map scores = new LinkedHashMap<>(); + scores.put("alice", 10L); + scores.put("bob", 20L); + assertEquals(0.0, + dist("SELECT * FROM t WHERE scores CONTAINS 10", row("scores", scores)), + DELTA); + } + + @Test + void contains_map_valueMissing_nonZero() { + Map scores = new LinkedHashMap<>(); + scores.put("alice", 10L); + double d = dist("SELECT * FROM t WHERE scores CONTAINS 99", row("scores", scores)); + assertTrue(d > 0.0); + } + + @Test + void contains_nullColumn_nonZeroDistance() { + assertTrue(dist("SELECT * FROM t WHERE col CONTAINS 1", row("col", null)) > 0.0); + } + + @Test + void contains_missingColumn_nonZeroDistance() { + assertTrue(dist("SELECT * FROM t WHERE col CONTAINS 1", row("other", 99L)) > 0.0); + } + + @Test + void contains_stringList_exactMatch_zeroDistance() { + assertEquals(0.0, + dist("SELECT * FROM t WHERE tags CONTAINS 'hello'", + row("tags", Arrays.asList("hello", "world"))), + DELTA); + } + + @Test + void contains_stringList_closerStringGivesBetterScore() { + double dClose = dist("SELECT * FROM t WHERE tags CONTAINS 'hello'", + row("tags", Arrays.asList("hell"))); + double dFar = dist("SELECT * FROM t WHERE tags CONTAINS 'hello'", + row("tags", Arrays.asList("xyz"))); + assertTrue(dClose < dFar); + } + + // CONTAINS KEY operator + + @Test + void containsKey_keyExists_zeroDistance() { + Map m = new LinkedHashMap<>(); + m.put("alice", 10L); + assertEquals(0.0, + dist("SELECT * FROM t WHERE meta CONTAINS KEY 'alice'", row("meta", m)), + DELTA); + } + + @Test + void containsKey_keyAbsent_nonZero() { + Map m = new LinkedHashMap<>(); + m.put("alice", 10L); + double d = dist("SELECT * FROM t WHERE meta CONTAINS KEY 'bob'", row("meta", m)); + assertTrue(d > 0.0); + } + + @Test + void containsKey_closerKeyGivesBetterScore() { + Map m = new LinkedHashMap<>(); + m.put("bob", 10L); // close to target "alice" (different string but not totally different) + double dClose = dist("SELECT * FROM t WHERE meta CONTAINS KEY 'alice'", row("meta", m)); + + Map m2 = new LinkedHashMap<>(); + m2.put("zzzzz", 10L); + double dFar = dist("SELECT * FROM t WHERE meta CONTAINS KEY 'alice'", row("meta", m2)); + + // "bob" vs "alice" vs "zzzzz" vs "alice" — both are non-matches but distances differ + // We only assert that both are > 0 (ordering may vary depending on leftAlignmentDistance) + assertTrue(dClose > 0.0); + assertTrue(dFar > 0.0); + } + + @Test + void containsKey_nullColumn_nonZeroDistance() { + assertTrue(dist("SELECT * FROM t WHERE col CONTAINS KEY 'x'", row("col", null)) > 0.0); + } + + @Test + void containsKey_nonMapColumn_nonZeroDistance() { + assertTrue(dist("SELECT * FROM t WHERE col CONTAINS KEY 'x'", + row("col", Arrays.asList("a", "b"))) > 0.0); + } + + // NULL handling + + @Test + void nullRowValue_returnsHighDistance() { + double d = dist("SELECT * FROM t WHERE col = 1", row("col", null)); + assertTrue(d > 0.0); + } + + @Test + void missingColumn_returnsHighDistance() { + double d = dist("SELECT * FROM t WHERE col = 1", row("other", 99L)); + assertTrue(d > 0.0); + } + + @Test + void nullCell_notWorseThanMissingColumn() { + // NULL cell → FALSE_TRUTHNESS_BETTER (C_BETTER=0.15 > C=0.1) + // Missing column → FALSE_TRUTHNESS (C=0.1) + // So NULL cell ofTrue > missing column ofTrue → distance(null) < distance(missing) + double dNull = dist("SELECT * FROM t WHERE col = 1", row("col", null)); + double dMissing = dist("SELECT * FROM t WHERE col = 1", row("other", 99L)); + assertTrue(dNull <= dMissing); + } + + @Test + void bothNullQueryAndRow_falseDistance() { + // Both NULL → FALSE_TRUTHNESS, not zero distance + double d = dist("SELECT * FROM t WHERE col = NULL", row("col", null)); + assertTrue(d > 0.0); + } + + // Multi-row scenarios + + @Test + void multiRow_oneMatchingRow_zeroDistance() { + assertEquals(0.0, + dist("SELECT * FROM t WHERE col = 5", + row("col", 1L), row("col", 5L), row("col", 10L)), + DELTA); + } + + @Test + void multiRow_noMatch_bestRowDrivesScore() { + double dWithClose = dist("SELECT * FROM t WHERE col = 10", + row("col", 11L), row("col", 100L)); + double dAllFar = dist("SELECT * FROM t WHERE col = 10", + row("col", 99L), row("col", 100L)); + assertTrue(dWithClose < dAllFar); + } + + @Test + void multiRow_earlyExitOnMatch() { + // Matching row is first — should short-circuit and not evaluate the rest + assertEquals(0.0, + dist("SELECT * FROM t WHERE col = 1", + row("col", 1L), row("col", 999L), row("col", 999L)), + DELTA); + } + + // Quoted column names + + @Test + void quotedColumnName_match_zeroDistance() { + // Parser preserves quotes; normalizeColumnName strips them and lowercases + assertEquals(0.0, + dist("SELECT * FROM t WHERE \"myCol\" = 42", row("mycol", 42L)), + DELTA); + } + + @Test + void quotedColumnName_mismatch_nonZero() { + double d = dist("SELECT * FROM t WHERE \"myCol\" = 42", row("mycol", 99L)); + assertTrue(d > 0.0); + } + + // Temporal types — date (LocalDate) + + @Test + void dateEquals_exactMatch_zeroDistance() { + LocalDate d = LocalDate.of(2023, 1, 15); + assertEquals(0.0, + dist("SELECT * FROM t WHERE d = '2023-01-15'", row("d", d)), + DELTA); + } + + @Test + void dateEquals_integerDaysSinceEpoch_zeroDistance() { + LocalDate d = LocalDate.of(2023, 1, 15); + assertEquals(0.0, + dist("SELECT * FROM t WHERE d = " + d.toEpochDay(), row("d", d)), + DELTA); + } + + @Test + void dateEquals_integerDaysSinceEpoch_nonZero() { + LocalDate d = LocalDate.of(2023, 1, 16); + double distance = dist("SELECT * FROM t WHERE d = " + LocalDate.of(2023, 1, 15).toEpochDay(), row("d", d)); + assertTrue(distance > 0.0 && distance < 1.0); + } + + @Test + void dateEquals_differentDay_nonZero() { + LocalDate d = LocalDate.of(2023, 1, 16); + double distance = dist("SELECT * FROM t WHERE d = '2023-01-15'", row("d", d)); + assertTrue(distance > 0.0 && distance < 1.0); + } + + @Test + void dateLT_satisfied_zeroDistance() { + LocalDate d = LocalDate.of(2022, 12, 31); + assertEquals(0.0, + dist("SELECT * FROM t WHERE d < '2023-01-01'", row("d", d)), + DELTA); + } + + @Test + void dateGT_satisfied_zeroDistance() { + LocalDate d = LocalDate.of(2023, 6, 1); + assertEquals(0.0, + dist("SELECT * FROM t WHERE d > '2023-01-01'", row("d", d)), + DELTA); + } + + @Test + void dateGTE_boundary_zeroDistance() { + LocalDate d = LocalDate.of(2023, 1, 1); + assertEquals(0.0, + dist("SELECT * FROM t WHERE d >= '2023-01-01'", row("d", d)), + DELTA); + } + + @Test + void dateLTE_boundary_zeroDistance() { + LocalDate d = LocalDate.of(2023, 1, 1); + assertEquals(0.0, + dist("SELECT * FROM t WHERE d <= '2023-01-01'", row("d", d)), + DELTA); + } + + @Test + void date_closerDayGivesBetterScore() { + double dClose = dist("SELECT * FROM t WHERE d = '2023-01-15'", + row("d", LocalDate.of(2023, 1, 16))); // 1 day away + double dFar = dist("SELECT * FROM t WHERE d = '2023-01-15'", + row("d", LocalDate.of(2023, 6, 1))); // months away + assertTrue(dClose < dFar); + } + + // Temporal types — time (LocalTime) + + @Test + void timeEquals_integerNanosSinceMidnight_zeroDistance() { + LocalTime t = LocalTime.of(14, 30, 0); + assertEquals(0.0, + dist("SELECT * FROM t WHERE t = " + t.toNanoOfDay(), row("t", t)), + DELTA); + } + + @Test + void timeEquals_integerNanosSinceMidnight_nonZero() { + LocalTime t = LocalTime.of(15, 0, 0); + double d = dist("SELECT * FROM t WHERE t = " + LocalTime.of(14, 30, 0).toNanoOfDay(), row("t", t)); + assertTrue(d > 0.0 && d < 1.0); + } + + @Test + void timeEquals_exactMatch_zeroDistance() { + LocalTime t = LocalTime.of(14, 30, 0); + assertEquals(0.0, + dist("SELECT * FROM t WHERE t = '14:30:00'", row("t", t)), + DELTA); + } + + @Test + void timeGT_satisfied_zeroDistance() { + LocalTime t = LocalTime.of(15, 0, 0); + assertEquals(0.0, + dist("SELECT * FROM t WHERE t > '14:30:00'", row("t", t)), + DELTA); + } + + @Test + void timeLT_notSatisfied_nonZero() { + LocalTime t = LocalTime.of(15, 0, 0); + double d = dist("SELECT * FROM t WHERE t < '14:30:00'", row("t", t)); + assertTrue(d > 0.0); + } + + @Test + void timeGTE_boundary_zeroDistance() { + LocalTime t = LocalTime.of(14, 30, 0); + assertEquals(0.0, + dist("SELECT * FROM t WHERE t >= '14:30:00'", row("t", t)), + DELTA); + } + + @Test + void timeGTE_notSatisfied_nonZero() { + LocalTime t = LocalTime.of(14, 0, 0); + assertTrue(dist("SELECT * FROM t WHERE t >= '14:30:00'", row("t", t)) > 0.0); + } + + @Test + void timeLTE_boundary_zeroDistance() { + LocalTime t = LocalTime.of(14, 30, 0); + assertEquals(0.0, + dist("SELECT * FROM t WHERE t <= '14:30:00'", row("t", t)), + DELTA); + } + + @Test + void timeLTE_notSatisfied_nonZero() { + LocalTime t = LocalTime.of(15, 0, 0); + assertTrue(dist("SELECT * FROM t WHERE t <= '14:30:00'", row("t", t)) > 0.0); + } + + // Temporal types — timestamp (Instant) + + @Test + void timestampEquals_exactMatch_zeroDistance() { + Instant ts = Instant.parse("2023-01-15T14:30:00Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = '2023-01-15T14:30:00Z'", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_differentTime_nonZero() { + Instant ts = Instant.parse("2023-01-15T15:00:00Z"); + double d = dist("SELECT * FROM t WHERE ts = '2023-01-15T14:30:00Z'", row("ts", ts)); + assertTrue(d > 0.0 && d < 1.0); + } + + @Test + void timestampGTE_boundary_zeroDistance() { + Instant ts = Instant.parse("2023-01-15T00:00:00Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts >= '2023-01-15T00:00:00Z'", row("ts", ts)), + DELTA); + } + + @Test + void timestamp_closerTimeGivesBetterScore() { + Instant target = Instant.parse("2023-01-15T12:00:00Z"); + double dClose = dist("SELECT * FROM t WHERE ts = '2023-01-15T12:00:00Z'", + row("ts", Instant.parse("2023-01-15T12:01:00Z"))); // 1 minute away + double dFar = dist("SELECT * FROM t WHERE ts = '2023-01-15T12:00:00Z'", + row("ts", Instant.parse("2023-01-16T12:00:00Z"))); // 1 day away + assertTrue(dClose < dFar); + } + + // Temporal types — timestamp additional constant formats + + @Test + void timestampEquals_integerEpochMs_zeroDistance() { + Instant ts = Instant.ofEpochMilli(1_299_038_700_000L); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = 1299038700000", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_integerEpochMs_nonZero() { + Instant ts = Instant.ofEpochMilli(1_299_038_700_000L + 3_600_000L); // 1 hour later + assertTrue(dist("SELECT * FROM t WHERE ts = 1299038700000", row("ts", ts)) > 0.0); + } + + @Test + void timestampEquals_spaceSeparatorNoSeconds_zeroDistance() { + Instant ts = Instant.parse("2011-02-03T04:05:00Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = '2011-02-03 04:05+0000'", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_spaceSeparatorWithSeconds_zeroDistance() { + Instant ts = Instant.parse("2011-02-03T04:05:00Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = '2011-02-03 04:05:00+0000'", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_spaceSeparatorWithMillis_zeroDistance() { + Instant ts = Instant.parse("2011-02-03T04:05:00.123Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = '2011-02-03 04:05:00.123+0000'", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_tSeparatorNoSeconds_zeroDistance() { + Instant ts = Instant.parse("2011-02-03T04:05:00Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = '2011-02-03T04:05+0000'", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_tSeparatorWithSeconds_zeroDistance() { + Instant ts = Instant.parse("2011-02-03T04:05:00Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = '2011-02-03T04:05:00+0000'", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_tSeparatorWithMillis_zeroDistance() { + Instant ts = Instant.parse("2011-02-03T04:05:00.123Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = '2011-02-03T04:05:00.123+0000'", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_dateOnly_zeroDistance() { + Instant ts = Instant.parse("2011-02-03T00:00:00Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = '2011-02-03'", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_dateOnlyWithOffset_zeroDistance() { + Instant ts = Instant.parse("2011-02-03T00:00:00Z"); + assertEquals(0.0, + dist("SELECT * FROM t WHERE ts = '2011-02-03+0000'", row("ts", ts)), + DELTA); + } + + @Test + void timestampEquals_spaceSeparator_differentTime_nonZero() { + Instant ts = Instant.parse("2011-02-03T05:00:00Z"); + double d = dist("SELECT * FROM t WHERE ts = '2011-02-03 04:05:00+0000'", row("ts", ts)); + assertTrue(d > 0.0 && d < 1.0); + } +} \ No newline at end of file diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/parser/CqlDurationLiteralParserTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/parser/CqlDurationLiteralParserTest.java new file mode 100644 index 0000000000..7dd0babf63 --- /dev/null +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/cassandra/parser/CqlDurationLiteralParserTest.java @@ -0,0 +1,234 @@ +package org.evomaster.client.java.controller.cassandra.parser; + +import org.evomaster.client.java.controller.cassandra.model.CqlDurationLiteral; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CqlDurationLiteralParserTest { + + private static final long NANOS_PER_HOUR = 3_600_000_000_000L; + private static final long NANOS_PER_MINUTE = 60_000_000_000L; + private static final long NANOS_PER_SECOND = 1_000_000_000L; + private static final long NANOS_PER_MILLISECOND = 1_000_000L; + private static final long NANOS_PER_MICROSECOND = 1_000L; + + private static void assertDuration(CqlDurationLiteral actual, int months, int days, long nanos) { + assertEquals(months, actual.months); + assertEquals(days, actual.days); + assertEquals(nanos, actual.nanos); + } + + // ------------------------------------------------------------------------- + // Standard Cassandra format + // ------------------------------------------------------------------------- + + @Test + void standardFormat_singleUnit_parsesDays() { + assertDuration(CqlDurationLiteralParser.parse("3d"), 0, 3, 0); + } + + @Test + void standardFormat_multipleUnitsCombined_parsesAllComponents() { + long expectedNanos = 4 * NANOS_PER_HOUR + 5 * NANOS_PER_MINUTE + 6 * NANOS_PER_SECOND; + assertDuration(CqlDurationLiteralParser.parse("1y2mo3d4h5m6s"), 14, 3, expectedNanos); + } + + @Test + void standardFormat_subSecondUnits_parsesNanos() { + long expectedNanos = 7 * NANOS_PER_MILLISECOND + 8 * NANOS_PER_MICROSECOND + 9; + assertDuration(CqlDurationLiteralParser.parse("7ms8us9ns"), 0, 0, expectedNanos); + } + + @Test + void standardFormat_microsecondMuVariant_parsesNanos() { + assertDuration(CqlDurationLiteralParser.parse("8µs"), 0, 0, 8 * NANOS_PER_MICROSECOND); + } + + @Test + void standardFormat_caseInsensitive_parsesUppercaseUnits() { + assertDuration(CqlDurationLiteralParser.parse("1Y2MO3D"), 14, 3, 0); + } + + @Test + void standardFormat_repeatedUnit_accumulates() { + assertDuration(CqlDurationLiteralParser.parse("1d2d"), 0, 3, 0); + } + + @Test + void standardFormat_weeks_convertToDays() { + assertDuration(CqlDurationLiteralParser.parse("2w3d"), 0, 17, 0); + } + + @Test + void standardFormat_negative_negatesAllComponents() { + assertDuration(CqlDurationLiteralParser.parse("-1mo3d"), -1, -3, 0); + } + + @Test + void standardFormat_negativeWeeks_negatesDays() { + assertDuration(CqlDurationLiteralParser.parse("-2w"), 0, -14, 0); + } + + @Test + void standardFormat_isolatedHour_parsesNanos() { + assertDuration(CqlDurationLiteralParser.parse("5h"), 0, 0, 5 * NANOS_PER_HOUR); + } + + @Test + void standardFormat_isolatedMinute_parsesNanos() { + assertDuration(CqlDurationLiteralParser.parse("5m"), 0, 0, 5 * NANOS_PER_MINUTE); + } + + @Test + void standardFormat_isolatedSecond_parsesNanos() { + assertDuration(CqlDurationLiteralParser.parse("5s"), 0, 0, 5 * NANOS_PER_SECOND); + } + + @Test + void standardFormat_unrecognizedCharacters_areSilentlyIgnored() { + // Documents current lenient behavior: the standard-format branch has no "whole string + // consumed" check, so unrecognized characters (here, "5x") are silently skipped rather + // than rejected. + assertDuration(CqlDurationLiteralParser.parse("5x3d"), 0, 3, 0); + } + + // ------------------------------------------------------------------------- + // ISO 8601 format + // ------------------------------------------------------------------------- + + @Test + void isoFormat_dateOnly_parsesMonthsAndDays() { + assertDuration(CqlDurationLiteralParser.parse("P1Y2M3D"), 14, 3, 0); + } + + @Test + void isoFormat_timeOnly_parsesNanos() { + long expectedNanos = 4 * NANOS_PER_HOUR + 5 * NANOS_PER_MINUTE + 6 * NANOS_PER_SECOND; + assertDuration(CqlDurationLiteralParser.parse("PT4H5M6S"), 0, 0, expectedNanos); + } + + @Test + void isoFormat_dateAndTimeCombined_parsesAllComponents() { + long expectedNanos = 4 * NANOS_PER_HOUR + 5 * NANOS_PER_MINUTE + 6 * NANOS_PER_SECOND; + assertDuration(CqlDurationLiteralParser.parse("P1Y2M3DT4H5M6S"), 14, 3, expectedNanos); + } + + @Test + void isoFormat_lowercasePrefix_isAccepted() { + assertDuration(CqlDurationLiteralParser.parse("p1y2m3d"), 14, 3, 0); + } + + @Test + void isoFormat_negativeDateOnly_negatesMonthsAndDays() { + assertDuration(CqlDurationLiteralParser.parse("-P1Y2M3D"), -14, -3, 0); + } + + @Test + void isoFormat_negativeTimeOnly_negatesNanos() { + assertDuration(CqlDurationLiteralParser.parse("-PT5H"), 0, 0, -5 * NANOS_PER_HOUR); + } + + @Test + void isoFormat_negativeDateAndTimeCombined_negatesAllComponents() { + long expectedNanos = 4 * NANOS_PER_HOUR + 5 * NANOS_PER_MINUTE + 6 * NANOS_PER_SECOND; + assertDuration(CqlDurationLiteralParser.parse("-P1Y2M3DT4H5M6S"), -14, -3, -expectedNanos); + } + + @Test + void isoFormat_barePrefix_parsesAsZeroDuration() { + // Mirrors the driver's own grammar, where every component is optional: a bare "P" is a + // valid, if degenerate, zero-length duration rather than an error. + assertDuration(CqlDurationLiteralParser.parse("P"), 0, 0, 0); + } + + @Test + void isoFormat_bareTimeDesignatorWithNoValue_parsesAsZeroDuration() { + assertDuration(CqlDurationLiteralParser.parse("PT"), 0, 0, 0); + } + + @Test + void isoFormat_dateOnlyWithBareTrailingTimeDesignator_parsesDateComponents() { + assertDuration(CqlDurationLiteralParser.parse("P1Y2M3DT"), 14, 3, 0); + } + + @Test + void isoFormat_malformedContent_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> CqlDurationLiteralParser.parse("P1X")); + } + + // ------------------------------------------------------------------------- + // ISO 8601, weeks-only format + // ------------------------------------------------------------------------- + + @Test + void isoWeekFormat_parsesDaysFromWeeks() { + assertDuration(CqlDurationLiteralParser.parse("P3W"), 0, 21, 0); + } + + @Test + void isoWeekFormat_negative_negatesDays() { + assertDuration(CqlDurationLiteralParser.parse("-P3W"), 0, -21, 0); + } + + @Test + void isoWeekFormat_lowercasePrefix_isAccepted() { + assertDuration(CqlDurationLiteralParser.parse("p3w"), 0, 21, 0); + } + + @Test + void isoWeekFormat_mixedWithOtherUnits_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> CqlDurationLiteralParser.parse("P1Y2W")); + } + + // ------------------------------------------------------------------------- + // ISO 8601, alternative format + // ------------------------------------------------------------------------- + + @Test + void isoAlternativeFormat_parsesAllComponents() { + long expectedNanos = 4 * NANOS_PER_HOUR + 5 * NANOS_PER_MINUTE + 6 * NANOS_PER_SECOND; + assertDuration(CqlDurationLiteralParser.parse("P0001-02-03T04:05:06"), 14, 3, expectedNanos); + } + + @Test + void isoAlternativeFormat_negative_negatesAllComponents() { + long expectedNanos = 4 * NANOS_PER_HOUR + 5 * NANOS_PER_MINUTE + 6 * NANOS_PER_SECOND; + assertDuration(CqlDurationLiteralParser.parse("-P0001-02-03T04:05:06"), -14, -3, -expectedNanos); + } + + @Test + void isoAlternativeFormat_lowercasePrefix_isAccepted() { + assertDuration(CqlDurationLiteralParser.parse("p0000-01-00T00:00:00"), 1, 0, 0); + } + + @Test + void isoAlternativeFormat_missingTimePart_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> CqlDurationLiteralParser.parse("P2024-01")); + } + + // ------------------------------------------------------------------------- + // Errors + // ------------------------------------------------------------------------- + + @Test + void parse_null_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> CqlDurationLiteralParser.parse(null)); + } + + @Test + void parse_empty_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> CqlDurationLiteralParser.parse("")); + } + + @Test + void parse_blank_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> CqlDurationLiteralParser.parse(" ")); + } + + @Test + void parse_bareMinusSign_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> CqlDurationLiteralParser.parse("-")); + } +} \ No newline at end of file diff --git a/client-java/distance-heuristics/src/main/java/org/evomaster/client/java/distance/heuristics/TruthnessUtils.java b/client-java/distance-heuristics/src/main/java/org/evomaster/client/java/distance/heuristics/TruthnessUtils.java index ef172d9590..46c3d46056 100644 --- a/client-java/distance-heuristics/src/main/java/org/evomaster/client/java/distance/heuristics/TruthnessUtils.java +++ b/client-java/distance-heuristics/src/main/java/org/evomaster/client/java/distance/heuristics/TruthnessUtils.java @@ -319,4 +319,26 @@ public static Truthness getEqualityTruthness(UUID left, UUID right) { ); } + /** + * Computes the {@link Truthness} of the predicate {@code a.equals(b)}. + * If the strings are equal, {@code ofTrue} is maximal (1.0). Otherwise, {@code ofTrue} is + * derived from a left-alignment distance between the two strings (via + * {@link DistanceHelper#getLeftAlignmentDistance}), so that strings sharing a longer common + * prefix yield a higher (closer-to-true) heuristic value. + * + * @param a the first string, must not be {@code null} + * @param b the second string, must not be {@code null} + * @return the Truthness of {@code a} and {@code b} being equal + */ + public static Truthness getStringEqualityTruthness(String a, String b) { + Objects.requireNonNull(a); + Objects.requireNonNull(b); + if (a.equals(b)) { + return new Truthness(1d, DistanceHelper.H_NOT_NULL); + } + long dist = DistanceHelper.getLeftAlignmentDistance(a, b); + double ofTrue = DistanceHelper.heuristicFromScaledDistanceWithBase(DistanceHelper.H_NOT_NULL, (double) dist); + return new Truthness(ofTrue, 1d); + } + } diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/ExecutedCqlCommand.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/ExecutedCqlCommand.java index e4bbdd5901..ac976a2527 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/ExecutedCqlCommand.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/ExecutedCqlCommand.java @@ -18,6 +18,16 @@ public class ExecutedCqlCommand implements Serializable { */ private final String cqlCommand; + /** + * The keyspace the command targets, if it could be determined from the query text; null otherwise + */ + private final String keyspaceName; + + /** + * The table the command targets, if it could be determined from the query text; null otherwise + */ + private final String tableName; + /** * Whether the CQL command failed, for any reason */ @@ -28,8 +38,10 @@ public class ExecutedCqlCommand implements Serializable { */ private final long executionTime; - public ExecutedCqlCommand(String cqlCommand, boolean threwCqlException, long executionTime) { + public ExecutedCqlCommand(String cqlCommand, String keyspaceName, String tableName, boolean threwCqlException, long executionTime) { this.cqlCommand = cqlCommand; + this.keyspaceName = keyspaceName; + this.tableName = tableName; this.threwCqlException = threwCqlException; this.executionTime = executionTime; } @@ -38,6 +50,14 @@ public String getCqlCommand() { return cqlCommand; } + public String getKeyspaceName() { + return keyspaceName; + } + + public String getTableName() { + return tableName; + } + public boolean hasThrownCqlException() { return threwCqlException; } diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/CqlSessionClassReplacement.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/CqlSessionClassReplacement.java index 641a68ebd0..59a2bbd771 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/CqlSessionClassReplacement.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/CqlSessionClassReplacement.java @@ -2,6 +2,7 @@ import org.evomaster.client.java.instrumentation.ExecutedCqlCommand; import org.evomaster.client.java.instrumentation.coverage.methodreplacement.Replacement; +import org.evomaster.client.java.instrumentation.coverage.methodreplacement.ThirdPartyCast; import org.evomaster.client.java.instrumentation.coverage.methodreplacement.ThirdPartyMethodReplacementClass; import org.evomaster.client.java.instrumentation.coverage.methodreplacement.UsageFilter; import org.evomaster.client.java.instrumentation.shared.ReplacementCategory; @@ -10,30 +11,72 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class CqlSessionClassReplacement extends ThirdPartyMethodReplacementClass { private static final CqlSessionClassReplacement singleton = new CqlSessionClassReplacement(); - public static final String CASSANDRA_FIND_STRING_SYNC = "cassandraExecuteStringSync"; + public static final String CASSANDRA_EXECUTE_STRING_SYNC = "cassandraExecuteStringSync"; + public static final String CASSANDRA_EXECUTE_STRING_POSITIONAL_VALUES_SYNC = "cassandraExecuteStringPositionalValuesSync"; + public static final String CASSANDRA_EXECUTE_STRING_NAMED_VALUES_SYNC = "cassandraExecuteStringNamedValuesSync"; + public static final String CASSANDRA_EXECUTE_STATEMENT_SYNC = "cassandraExecuteStatementSync"; + + private static final String RESULT_SET_CLASS = "com.datastax.oss.driver.api.core.cql.ResultSet"; + private static final String STATEMENT_CLASS = "com.datastax.oss.driver.api.core.cql.Statement"; + + // a CQL identifier: either quoted+case-sensitive (e.g. "Tbl") or unquoted (e.g. tbl) + private static final String IDENTIFIER = "(?:\"[^\"]+\"|[A-Za-z_]\\w*)"; + // clause keywords that introduce a table reference (SELECT/DELETE ... FROM, INSERT ... INTO, UPDATE ...) + private static final String CLAUSE_KEYWORDS = "(?:FROM|INTO|UPDATE)"; + + /** + * Matches the keyspace/table reference after FROM (SELECT/DELETE), INTO (INSERT), or + * UPDATE, e.g. "FROM ks.tbl", "INTO tbl", "UPDATE \"Ks\".\"Tbl\"". Group "first" is the + * keyspace when a "keyspace.table" qualifier ("second") is present, otherwise it's the + * table name on its own. + */ + private static final Pattern TABLE_REFERENCE_PATTERN = Pattern.compile( + "(?i)\\b" + CLAUSE_KEYWORDS + "\\s+" + + "(?" + IDENTIFIER + ")" + + "(?:\\s*\\.\\s*(?" + IDENTIFIER + "))?" + ); @Override protected String getNameOfThirdPartyTargetClass() { return "com.datastax.oss.driver.api.core.CqlSession"; } - @Replacement(type = ReplacementType.TRACKER, id = CASSANDRA_FIND_STRING_SYNC, usageFilter = UsageFilter.ANY, category = ReplacementCategory.CASSANDRA, castTo = "com.datastax.oss.driver.api.core.cql.ResultSet") + @Replacement(type = ReplacementType.TRACKER, id = CASSANDRA_EXECUTE_STRING_SYNC, usageFilter = UsageFilter.ANY, category = ReplacementCategory.CASSANDRA, castTo = RESULT_SET_CLASS) public static Object execute(Object cqlSession, String query) { - return handleCqlExecute(CASSANDRA_FIND_STRING_SYNC, cqlSession, query); + return handleCqlExecute(CASSANDRA_EXECUTE_STRING_SYNC, cqlSession, query, query); + } + + @Replacement(type = ReplacementType.TRACKER, id = CASSANDRA_EXECUTE_STRING_POSITIONAL_VALUES_SYNC, usageFilter = UsageFilter.ANY, category = ReplacementCategory.CASSANDRA, castTo = RESULT_SET_CLASS) + public static Object execute(Object cqlSession, String query, Object... values) { + return handleCqlExecute(CASSANDRA_EXECUTE_STRING_POSITIONAL_VALUES_SYNC, cqlSession, query, query, values); + } + + @Replacement(type = ReplacementType.TRACKER, id = CASSANDRA_EXECUTE_STRING_NAMED_VALUES_SYNC, usageFilter = UsageFilter.ANY, category = ReplacementCategory.CASSANDRA, castTo = RESULT_SET_CLASS) + public static Object execute(Object cqlSession, String query, Map values) { + return handleCqlExecute(CASSANDRA_EXECUTE_STRING_NAMED_VALUES_SYNC, cqlSession, query, query, values); } - private static Object handleCqlExecute(String id, Object cqlSession, String query) { + @Replacement(type = ReplacementType.TRACKER, id = CASSANDRA_EXECUTE_STATEMENT_SYNC, usageFilter = UsageFilter.ANY, category = ReplacementCategory.CASSANDRA, castTo = RESULT_SET_CLASS) + public static Object execute(Object cqlSession, @ThirdPartyCast(actualType = STATEMENT_CLASS) Object statement) { + return handleCqlExecute(CASSANDRA_EXECUTE_STATEMENT_SYNC, cqlSession, extractQueryText(statement), statement); + } + + private static Object handleCqlExecute(String id, Object cqlSession, String queryForTracking, Object... invokeArgs) { long start = System.currentTimeMillis(); try { Method executeMethod = retrieveExecuteMethod(id, cqlSession); - Object result = executeMethod.invoke(cqlSession, query); + Object result = executeMethod.invoke(cqlSession, invokeArgs); long end = System.currentTimeMillis(); long executionTime = end - start; - ExecutedCqlCommand info = new ExecutedCqlCommand(query, false, executionTime); + TableReference ref = extractTableReference(queryForTracking); + ExecutedCqlCommand info = new ExecutedCqlCommand(queryForTracking, ref.keyspaceName, ref.tableName, false, executionTime); ExecutionTracer.addCqlInfo(info); return result; } catch (IllegalAccessException e) { @@ -43,6 +86,68 @@ private static Object handleCqlExecute(String id, Object cqlSession, String quer } } + /** + * Best-effort extraction of keyspace/table from the CQL text. Returns both fields as + * null when the query doesn't match a recognised SELECT/INSERT/UPDATE/DELETE shape + * (eg DDL, batches). + */ + private static TableReference extractTableReference(String query) { + if (query == null) { + return new TableReference(null, null); + } + + Matcher matcher = TABLE_REFERENCE_PATTERN.matcher(query); + if (!matcher.find()) { + return new TableReference(null, null); + } else { + String first = stripQuotes(matcher.group("first")); + String second = matcher.group("second") != null ? stripQuotes(matcher.group("second")) : null; + // if there is an "a.b" qualifier, "a" is the keyspace and "b" is the table; + // otherwise the single identifier is the table, and the keyspace is the session default + return second != null ? new TableReference(first, second) : new TableReference(null, first); + } + } + + private static String stripQuotes(String identifier) { + if (identifier.length() >= 2 && identifier.charAt(0) == '"' && identifier.charAt(identifier.length() - 1) == '"') { + return identifier.substring(1, identifier.length() - 1); + } else { + return identifier; + } + } + + private static class TableReference { + final String keyspaceName; + final String tableName; + + TableReference(String keyspaceName, String tableName) { + this.keyspaceName = keyspaceName; + this.tableName = tableName; + } + } + + /** + * Statement is a generic driver type; only SimpleStatement exposes the original + * CQL text directly, while BoundStatement requires going through its PreparedStatement. + */ + private static String extractQueryText(Object statement) { + try { + Method getQuery = statement.getClass().getMethod("getQuery"); + return (String) getQuery.invoke(statement); + } catch (ReflectiveOperationException e) { + // not a SimpleStatement (eg BoundStatement) -- fall through + } + + try { + Method getPreparedStatement = statement.getClass().getMethod("getPreparedStatement"); + Object prepared = getPreparedStatement.invoke(statement); + Method getQuery = prepared.getClass().getMethod("getQuery"); + return (String) getQuery.invoke(prepared); + } catch (ReflectiveOperationException e) { + return statement.toString(); // eg BatchStatement -- best effort + } + } + private static Method retrieveExecuteMethod(String id, Object cqlSession){ return getOriginal(singleton, id, cqlSession); } diff --git a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/CqlSessionClassReplacementTest.java b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/CqlSessionClassReplacementTest.java index 38a80e78d4..7cd2a85532 100644 --- a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/CqlSessionClassReplacementTest.java +++ b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/CqlSessionClassReplacementTest.java @@ -1,6 +1,9 @@ package org.evomaster.client.java.instrumentation.coverage.methodreplacement.thirdpartyclasses; import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.BoundStatement; +import com.datastax.oss.driver.api.core.cql.PreparedStatement; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; import org.evomaster.client.java.instrumentation.AdditionalInfo; import org.evomaster.client.java.instrumentation.ExecutedCqlCommand; import org.evomaster.client.java.instrumentation.staticstate.ExecutionTracer; @@ -13,8 +16,7 @@ import java.net.InetSocketAddress; import java.time.Duration; -import java.util.List; -import java.util.Set; +import java.util.*; import static org.junit.jupiter.api.Assertions.*; @@ -31,7 +33,8 @@ public class CqlSessionClassReplacementTest { .withStartupTimeout(Duration.ofMinutes(2))); private static final String KEYSPACE = "testks"; - private static final String TABLE = KEYSPACE + ".users"; + private static final String TABLE_NAME = "users"; + private static final String TABLE = KEYSPACE + "." + TABLE_NAME; @BeforeAll static void startCassandra() { @@ -80,6 +83,8 @@ void testExecuteSelectIsTracked() { ExecutedCqlCommand cmd = commands.iterator().next(); assertEquals(query, cmd.getCqlCommand()); + assertEquals(KEYSPACE, cmd.getKeyspaceName()); + assertEquals(TABLE_NAME, cmd.getTableName()); assertFalse(cmd.hasThrownCqlException()); assertTrue(cmd.getExecutionTime() >= 0); } @@ -98,6 +103,8 @@ void testExecuteInsertIsTracked() { ExecutedCqlCommand cmd = commands.iterator().next(); assertEquals(query, cmd.getCqlCommand()); + assertEquals(KEYSPACE, cmd.getKeyspaceName()); + assertEquals(TABLE_NAME, cmd.getTableName()); assertFalse(cmd.hasThrownCqlException()); assertTrue(cmd.getExecutionTime() >= 0); } @@ -140,4 +147,92 @@ void testExecutingInitCassandraFlagSuppressesTracking() { assertEquals(1, additionalInfoList.size()); assertTrue(additionalInfoList.get(0).getCqlInfoData().isEmpty()); } + + @Test + void testExecuteWithPositionalValuesIsTracked() { + String query = "INSERT INTO " + TABLE + " (id, name, age) VALUES (?, ?, ?)"; + + CqlSessionClassReplacement.execute(cqlSession, query, UUID.randomUUID(), "Dave", 40); + + List additionalInfoList = ExecutionTracer.exposeAdditionalInfoList(); + assertEquals(1, additionalInfoList.size()); + + Set commands = additionalInfoList.get(0).getCqlInfoData(); + assertEquals(1, commands.size()); + + ExecutedCqlCommand cmd = commands.iterator().next(); + assertEquals(query, cmd.getCqlCommand()); + assertEquals(KEYSPACE, cmd.getKeyspaceName()); + assertEquals(TABLE_NAME, cmd.getTableName()); + assertFalse(cmd.hasThrownCqlException()); + assertTrue(cmd.getExecutionTime() >= 0); + } + + @Test + void testExecuteWithNamedValuesIsTracked() { + String query = "INSERT INTO " + TABLE + " (id, name, age) VALUES (:id, :name, :age)"; + Map values = new HashMap<>(); + values.put("id", UUID.randomUUID()); + values.put("name", "Erin"); + values.put("age", 22); + + CqlSessionClassReplacement.execute(cqlSession, query, values); + + List additionalInfoList = ExecutionTracer.exposeAdditionalInfoList(); + assertEquals(1, additionalInfoList.size()); + + Set commands = additionalInfoList.get(0).getCqlInfoData(); + assertEquals(1, commands.size()); + + ExecutedCqlCommand cmd = commands.iterator().next(); + assertEquals(query, cmd.getCqlCommand()); + assertEquals(KEYSPACE, cmd.getKeyspaceName()); + assertEquals(TABLE_NAME, cmd.getTableName()); + assertFalse(cmd.hasThrownCqlException()); + assertTrue(cmd.getExecutionTime() >= 0); + } + + @Test + void testExecuteWithSimpleStatementIsTracked() { + String query = "SELECT * FROM " + TABLE; + SimpleStatement statement = SimpleStatement.newInstance(query); + + CqlSessionClassReplacement.execute(cqlSession, statement); + + List additionalInfoList = ExecutionTracer.exposeAdditionalInfoList(); + assertEquals(1, additionalInfoList.size()); + + Set commands = additionalInfoList.get(0).getCqlInfoData(); + assertEquals(1, commands.size()); + + ExecutedCqlCommand cmd = commands.iterator().next(); + assertEquals(query, cmd.getCqlCommand()); + assertEquals(KEYSPACE, cmd.getKeyspaceName()); + assertEquals(TABLE_NAME, cmd.getTableName()); + assertFalse(cmd.hasThrownCqlException()); + assertTrue(cmd.getExecutionTime() >= 0); + } + + @Test + void testExecuteWithBoundStatementIsTracked() { + String query = "INSERT INTO " + TABLE + " (id, name, age) VALUES (?, ?, ?)"; + // Preparing directly on the session so it is NOT intercepted by the replacement + PreparedStatement prepared = cqlSession.prepare(query); + BoundStatement bound = prepared.bind(UUID.randomUUID(), "Frank", 33); + + CqlSessionClassReplacement.execute(cqlSession, bound); + + List additionalInfoList = ExecutionTracer.exposeAdditionalInfoList(); + assertEquals(1, additionalInfoList.size()); + + Set commands = additionalInfoList.get(0).getCqlInfoData(); + assertEquals(1, commands.size()); + + ExecutedCqlCommand cmd = commands.iterator().next(); + assertEquals(query, cmd.getCqlCommand()); + assertEquals(KEYSPACE, cmd.getKeyspaceName()); + assertEquals(TABLE_NAME, cmd.getTableName()); + assertFalse(cmd.hasThrownCqlException()); + assertTrue(cmd.getExecutionTime() >= 0); + } } diff --git a/client-java/sql/src/main/java/org/evomaster/client/java/sql/heuristic/SqlExpressionEvaluator.java b/client-java/sql/src/main/java/org/evomaster/client/java/sql/heuristic/SqlExpressionEvaluator.java index af342f8517..ced3ac493a 100644 --- a/client-java/sql/src/main/java/org/evomaster/client/java/sql/heuristic/SqlExpressionEvaluator.java +++ b/client-java/sql/src/main/java/org/evomaster/client/java/sql/heuristic/SqlExpressionEvaluator.java @@ -12,7 +12,6 @@ import net.sf.jsqlparser.statement.select.AllTableColumns; import net.sf.jsqlparser.statement.select.ParenthesedSelect; import net.sf.jsqlparser.statement.select.Select; -import org.evomaster.client.java.distance.heuristics.DistanceHelper; import org.evomaster.client.java.distance.heuristics.Truthness; import org.evomaster.client.java.distance.heuristics.TruthnessUtils; import org.evomaster.client.java.instrumentation.shared.RegexSharedUtils; @@ -20,19 +19,20 @@ import org.evomaster.client.java.sql.heuristic.function.FunctionFinder; import org.evomaster.client.java.sql.heuristic.function.SqlAggregateFunction; import org.evomaster.client.java.sql.heuristic.function.SqlFunction; -import org.evomaster.client.java.sql.internal.*; - +import org.evomaster.client.java.sql.internal.TaintHandler; import java.sql.Timestamp; -import java.time.*; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.OffsetTime; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.evomaster.client.java.distance.heuristics.TruthnessUtils.*; import static org.evomaster.client.java.sql.heuristic.ConversionHelper.*; import static org.evomaster.client.java.sql.heuristic.SqlCastHelper.castTo; import static org.evomaster.client.java.sql.heuristic.SqlHeuristicsCalculator.*; -import static org.evomaster.client.java.distance.heuristics.TruthnessUtils.*; import static org.evomaster.client.java.sql.heuristic.SqlStringUtils.nullSafeEqualsIgnoreCase; public class SqlExpressionEvaluator extends ExpressionVisitorAdapter { @@ -396,20 +396,6 @@ private static double toDouble(Boolean booleanValue) { return (booleanValue ? 1d : 0d); } - public static Truthness getEqualityTruthness(String a, String b) { - Objects.requireNonNull(a); - Objects.requireNonNull(b); - - if (a.equals(b)) { - return TRUE_TRUTHNESS; - } else { - final double base = C; - final double distance = DistanceHelper.getLeftAlignmentDistance(a, b); - final double h = DistanceHelper.heuristicFromScaledDistanceWithBase(base, distance); - return new Truthness(h, 1d); - } - } - private Truthness calculateTruthnessForStringComparison(String leftString, String rightString, ComparisonOperatorType comparisonOperatorType) { Objects.requireNonNull(leftString); Objects.requireNonNull(rightString); @@ -419,9 +405,9 @@ private Truthness calculateTruthnessForStringComparison(String leftString, Strin if (taintHandler != null) { taintHandler.handleTaintForStringEquals(leftString, rightString, false); } - return getEqualityTruthness(leftString, rightString); + return TruthnessUtils.getStringEqualityTruthness(leftString, rightString); case NOT_EQUALS_TO: - return getEqualityTruthness(leftString, rightString).invert(); + return TruthnessUtils.getStringEqualityTruthness(leftString, rightString).invert(); case GREATER_THAN: return leftString.compareTo(rightString) > 0 ? TRUE_TRUTHNESS : FALSE_TRUTHNESS; case GREATER_THAN_EQUALS: