diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/AdditionalInfo.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/AdditionalInfo.java index 9493cfb078..72aba7a97e 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/AdditionalInfo.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/AdditionalInfo.java @@ -1,5 +1,6 @@ package org.evomaster.client.java.instrumentation; +import org.evomaster.client.java.instrumentation.cassandra.CassandraTableMetadata; import org.evomaster.client.java.instrumentation.shared.StringSpecializationInfo; import org.evomaster.client.java.instrumentation.staticstate.ExecutionTracer; import org.evomaster.client.java.utils.SimpleLogger; @@ -118,6 +119,8 @@ public StatementDescription(String line, String method) { private final Set mongoCollectionSchemaData = new CopyOnWriteArraySet<>(); + private final Set cassandraTableMetadataData = new CopyOnWriteArraySet<>(); + public Set getSqlInfoData(){ return Collections.unmodifiableSet(executedSqlCommandData); } @@ -150,6 +153,10 @@ public Set getMongoCollectionTypeData(){ return Collections.unmodifiableSet(mongoCollectionSchemaData); } + public Set getCassandraTableMetadataData(){ + return Collections.unmodifiableSet(cassandraTableMetadataData); + } + public void addSqlInfo(ExecutedSqlCommand info){ executedSqlCommandData.add(info); } @@ -182,6 +189,10 @@ public void addMongoCollectionType(MongoCollectionSchema mongoCollectionSchema){ mongoCollectionSchemaData.add(mongoCollectionSchema); } + public void addCassandraTableMetadata(CassandraTableMetadata cassandraTableMetadata){ + cassandraTableMetadataData.add(cassandraTableMetadata); + } + public Set getParsedDtoNamesView(){ return Collections.unmodifiableSet(parsedDtoNames); } diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/cassandra/CassandraColumnMetadata.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/cassandra/CassandraColumnMetadata.java new file mode 100644 index 0000000000..33cf71ffd7 --- /dev/null +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/cassandra/CassandraColumnMetadata.java @@ -0,0 +1,55 @@ +package org.evomaster.client.java.instrumentation.cassandra; + +import java.io.Serializable; +import java.util.Objects; + +/** + * A single column of a Cassandra table, as captured from the driver's table metadata: its name, + * CQL type, and whether it belongs to the partition key or is a clustering column. + */ +public class CassandraColumnMetadata implements Serializable { + + private final String name; + private final String cqlType; + private final boolean partitionKey; + private final boolean clusteringColumn; + + public CassandraColumnMetadata(String name, String cqlType, boolean partitionKey, boolean clusteringColumn) { + this.name = name; + this.cqlType = cqlType; + this.partitionKey = partitionKey; + this.clusteringColumn = clusteringColumn; + } + + public String getName() { + return name; + } + + public String getCqlType() { + return cqlType; + } + + public boolean isPartitionKey() { + return partitionKey; + } + + public boolean isClusteringColumn() { + return clusteringColumn; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CassandraColumnMetadata)) return false; + CassandraColumnMetadata that = (CassandraColumnMetadata) o; + return partitionKey == that.partitionKey + && clusteringColumn == that.clusteringColumn + && Objects.equals(name, that.name) + && Objects.equals(cqlType, that.cqlType); + } + + @Override + public int hashCode() { + return Objects.hash(name, cqlType, partitionKey, clusteringColumn); + } +} \ No newline at end of file diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/cassandra/CassandraSchemaTracer.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/cassandra/CassandraSchemaTracer.java new file mode 100644 index 0000000000..dc3f1aab2a --- /dev/null +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/cassandra/CassandraSchemaTracer.java @@ -0,0 +1,204 @@ +package org.evomaster.client.java.instrumentation.cassandra; + +import org.evomaster.client.java.instrumentation.staticstate.ExecutionTracer; + +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Caches Cassandra table schemas read directly from the driver's own metadata + * ({@code CqlSession.getMetadata()}), rather than from Spring Data hooks. This works regardless of + * whether the SUT talks to Cassandra through Spring Data repositories, {@code CassandraTemplate}, + * or a bare {@code CqlSession}, since the driver always carries this information. + *

+ * {@link #resolve(Object, String, String)} is called from + * {@link org.evomaster.client.java.instrumentation.coverage.methodreplacement.thirdpartyclasses.CqlSessionClassReplacement} + * right after every successfully executed CQL statement, so the cache is populated as a side effect + * of real SUT traffic: a table's shape is fetched once, the first time it's referenced, traced via + * {@link ExecutionTracer#addCassandraTableMetadata(CassandraTableMetadata)}, and served straight + * from the cache (without re-tracing it) on every later reference, since {@code TableMetadata} is + * never partial (a single lookup already returns every column of a table). + *

+ * The cache is held in a static field rather than an injected instance: it mirrors + * {@link org.evomaster.client.java.instrumentation.object.ClassToSchema}'s cache, and needs to be + * reachable from the static replacement methods on {@code CqlSessionClassReplacement}. + */ +public class CassandraSchemaTracer { + + private static final String CQL_IDENTIFIER_CLASS = "com.datastax.oss.driver.api.core.CqlIdentifier"; + + /* + * Names of the driver methods invoked via reflection throughout this class. + */ + private static final String METHOD_GET_METADATA = "getMetadata"; + private static final String METHOD_GET_KEYSPACE = "getKeyspace"; + private static final String METHOD_GET_TABLE = "getTable"; + private static final String METHOD_GET_NAME = "getName"; + private static final String METHOD_GET_TYPE = "getType"; + private static final String METHOD_FROM_CQL = "fromCql"; + private static final String METHOD_IS_PRESENT = "isPresent"; + private static final String METHOD_GET = "get"; + private static final String METHOD_AS_INTERNAL = "asInternal"; + private static final String METHOD_AS_CQL = "asCql"; + private static final String METHOD_GET_PARTITION_KEY = "getPartitionKey"; + private static final String METHOD_GET_CLUSTERING_COLUMNS = "getClusteringColumns"; + private static final String METHOD_GET_COLUMNS = "getColumns"; + + /** + * Key -> the captured shape of a keyspace's table. + * Value -> its columns, types, and primary-key roles, as last read from the driver. + */ + private static final Map tables = new ConcurrentHashMap<>(); + + private CassandraSchemaTracer() { + } + + /** + * Resets the tracer's status + */ + public static void reset() { + tables.clear(); + } + + /** + * Resolves the shape of a keyspace/table. Tables seen before are served straight from the + * cache; a table not seen before (e.g. queried for the first time) is fetched once and cached. + * + * @param cqlSession a live {@code com.datastax.oss.driver.api.core.CqlSession}, accessed + * purely via reflection so this module has no compile-time driver dependency + * @param keyspaceNameRaw the raw, quote-preserving keyspace text as it appeared in the CQL + * statement, or {@code null} if the statement didn't qualify the table + * with a keyspace (the session's current keyspace applies instead) + * @param tableNameRaw the raw, quote-preserving table text as it appeared in the CQL statement + * @return the table's shape, or {@code null} if the keyspace/table can't be resolved + * (unqualified reference with no current keyspace set, or the keyspace/table doesn't exist) + */ + public static CassandraTableMetadata resolve(Object cqlSession, String keyspaceNameRaw, String tableNameRaw) { + try { + Object keyspaceIdentifier = keyspaceNameRaw != null + ? cqlIdentifierFromCql(keyspaceNameRaw) + : currentKeyspaceIdentifier(cqlSession); + if (keyspaceIdentifier == null) { + return null; + } + + Object metadata = invoke(cqlSession, METHOD_GET_METADATA); + Object keyspaceMetadata = unwrapOptional(invokeWithCqlIdentifier(metadata, METHOD_GET_KEYSPACE, keyspaceIdentifier)); + if (keyspaceMetadata == null) { + return null; + } + + Object tableIdentifier = cqlIdentifierFromCql(tableNameRaw); + Object tableMetadataObj = unwrapOptional(invokeWithCqlIdentifier(keyspaceMetadata, METHOD_GET_TABLE, tableIdentifier)); + if (tableMetadataObj == null) { + return null; + } + + String keyspaceName = asInternal(invoke(keyspaceMetadata, METHOD_GET_NAME)); + String tableName = asInternal(invoke(tableMetadataObj, METHOD_GET_NAME)); + TableKey key = new TableKey(keyspaceName, tableName); + + // fast path: skip re-walking columns/partition-key/clustering-columns on a cache hit, + // since this is called on every intercepted query + CassandraTableMetadata cached = tables.get(key); + if (cached != null) { + return cached; + } + + CassandraTableMetadata tableMetadata = buildTableMetadata(keyspaceName, tableMetadataObj); + tables.put(key, tableMetadata); + // first time this table is seen: trace its schema, mirroring how it used to be captured + // from Spring Data + ExecutionTracer.addCassandraTableMetadata(tableMetadata); + return tableMetadata; + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Failed to resolve Cassandra table metadata for " + + keyspaceNameRaw + "." + tableNameRaw, e); + } + } + + private static CassandraTableMetadata buildTableMetadata(String keyspaceName, Object tableMetadataObj) throws ReflectiveOperationException { + String tableName = asInternal(invoke(tableMetadataObj, METHOD_GET_NAME)); + + Set partitionKeyNames = namesOf((List) invoke(tableMetadataObj, METHOD_GET_PARTITION_KEY)); + Set clusteringNames = namesOf(((Map) invoke(tableMetadataObj, METHOD_GET_CLUSTERING_COLUMNS)).keySet()); + + Map columnsByName = (Map) invoke(tableMetadataObj, METHOD_GET_COLUMNS); + List columns = new ArrayList<>(); + for (Object columnMetadataObj : columnsByName.values()) { + String columnName = asInternal(invoke(columnMetadataObj, METHOD_GET_NAME)); + String cqlType = asCql(invoke(columnMetadataObj, METHOD_GET_TYPE)); + columns.add(new CassandraColumnMetadata(columnName, cqlType, + partitionKeyNames.contains(columnName), clusteringNames.contains(columnName))); + } + return new CassandraTableMetadata(keyspaceName, tableName, columns); + } + + private static Set namesOf(Iterable columnMetadataObjs) throws ReflectiveOperationException { + Set names = new HashSet<>(); + for (Object columnMetadataObj : columnMetadataObjs) { + names.add(asInternal(invoke(columnMetadataObj, METHOD_GET_NAME))); + } + return names; + } + + private static Object currentKeyspaceIdentifier(Object cqlSession) throws ReflectiveOperationException { + return unwrapOptional(invoke(cqlSession, METHOD_GET_KEYSPACE)); + } + + private static Object cqlIdentifierFromCql(String rawText) throws ReflectiveOperationException { + Class cqlIdentifierClass = Class.forName(CQL_IDENTIFIER_CLASS); + return cqlIdentifierClass.getMethod(METHOD_FROM_CQL, String.class).invoke(null, rawText); + } + + private static Object invokeWithCqlIdentifier(Object target, String methodName, Object cqlIdentifierArg) throws ReflectiveOperationException { + Class cqlIdentifierClass = Class.forName(CQL_IDENTIFIER_CLASS); + Method method = target.getClass().getMethod(methodName, cqlIdentifierClass); + return method.invoke(target, cqlIdentifierArg); + } + + private static Object unwrapOptional(Object optional) throws ReflectiveOperationException { + boolean present = (boolean) invoke(optional, METHOD_IS_PRESENT); + return present ? invoke(optional, METHOD_GET) : null; + } + + private static String asInternal(Object cqlIdentifier) throws ReflectiveOperationException { + return (String) invoke(cqlIdentifier, METHOD_AS_INTERNAL); + } + + private static String asCql(Object dataType) throws ReflectiveOperationException { + return (String) dataType.getClass().getMethod(METHOD_AS_CQL, boolean.class, boolean.class).invoke(dataType, false, false); + } + + private static Object invoke(Object target, String methodName) throws ReflectiveOperationException { + return target.getClass().getMethod(methodName).invoke(target); + } + + /** + * Cache key: a table's resolved (never null) keyspace and table name, in the driver's + * canonical/internal form. + */ + private static class TableKey { + private final String keyspaceName; + private final String tableName; + + TableKey(String keyspaceName, String tableName) { + this.keyspaceName = keyspaceName; + this.tableName = tableName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TableKey)) return false; + TableKey tableKey = (TableKey) o; + return Objects.equals(keyspaceName, tableKey.keyspaceName) && Objects.equals(tableName, tableKey.tableName); + } + + @Override + public int hashCode() { + return Objects.hash(keyspaceName, tableName); + } + } +} \ No newline at end of file diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/cassandra/CassandraTableMetadata.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/cassandra/CassandraTableMetadata.java new file mode 100644 index 0000000000..ce82352f38 --- /dev/null +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/cassandra/CassandraTableMetadata.java @@ -0,0 +1,69 @@ +package org.evomaster.client.java.instrumentation.cassandra; + +import java.io.Serializable; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** + * The captured shape of a Cassandra table: its full column list, with CQL types and + * partition-key/clustering-column roles, as read once from the driver's table metadata. + */ +public class CassandraTableMetadata implements Serializable { + + private final String keyspaceName; + private final String tableName; + private final List columns; + + public CassandraTableMetadata(String keyspaceName, String tableName, List columns) { + Objects.requireNonNull(tableName); + assertNoDuplicateColumns(tableName, columns); + + this.keyspaceName = keyspaceName; + this.tableName = tableName; + this.columns = Collections.unmodifiableList(columns); + } + + private static void assertNoDuplicateColumns(String tableName, List columns) { + if (duplicateColumns(columns)) { + throw new IllegalArgumentException("Duplicate column name(s) in table metadata for " + tableName); + } + } + + private static boolean duplicateColumns(List columns) { + return columns.stream().map(CassandraColumnMetadata::getName).distinct().count() != columns.size(); + } + + public String getKeyspaceName() { + return keyspaceName; + } + + public String getTableName() { + return tableName; + } + + public List getColumns() { + return columns; + } + + /** + * Structural equality, ignoring column order: two captures of the same table are considered + * equal if they have the same columns with the same types and primary-key roles, regardless + * of the (driver-internal, not CQL-meaningful) iteration order they were read in. + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CassandraTableMetadata)) return false; + CassandraTableMetadata that = (CassandraTableMetadata) o; + return Objects.equals(keyspaceName, that.keyspaceName) + && Objects.equals(tableName, that.tableName) && Objects.equals(new HashSet<>(columns), new HashSet(that.columns)); + } + + @Override + public int hashCode() { + return Objects.hash(keyspaceName, tableName, new HashSet<>(columns)); + } + +} \ No newline at end of file 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 59a2bbd771..2b86b5194a 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 @@ -1,6 +1,7 @@ package org.evomaster.client.java.instrumentation.coverage.methodreplacement.thirdpartyclasses; import org.evomaster.client.java.instrumentation.ExecutedCqlCommand; +import org.evomaster.client.java.instrumentation.cassandra.CassandraSchemaTracer; 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; @@ -8,6 +9,7 @@ import org.evomaster.client.java.instrumentation.shared.ReplacementCategory; import org.evomaster.client.java.instrumentation.shared.ReplacementType; import org.evomaster.client.java.instrumentation.staticstate.ExecutionTracer; +import org.evomaster.client.java.utils.SimpleLogger; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -76,6 +78,9 @@ private static Object handleCqlExecute(String id, Object cqlSession, String quer long end = System.currentTimeMillis(); long executionTime = end - start; TableReference ref = extractTableReference(queryForTracking); + if (ref.rawTableName != null) { + captureTableSchema(cqlSession, ref); + } ExecutedCqlCommand info = new ExecutedCqlCommand(queryForTracking, ref.keyspaceName, ref.tableName, false, executionTime); ExecutionTracer.addCqlInfo(info); return result; @@ -86,6 +91,21 @@ private static Object handleCqlExecute(String id, Object cqlSession, String quer } } + /** + * Best-effort: caches the queried table's schema, read directly from the driver's own + * metadata, so it's available later without depending on Spring Data. Uses the raw, + * quote-preserving keyspace/table text (not the lower-cased, quote-stripped fields on + * {@link TableReference} used for {@link ExecutedCqlCommand}), since CQL treats quoted + * identifiers as case-sensitive. Never lets a schema-capture failure break the SUT's own query. + */ + private static void captureTableSchema(Object cqlSession, TableReference ref) { + try { + CassandraSchemaTracer.resolve(cqlSession, ref.rawKeyspaceName, ref.rawTableName); + } catch (RuntimeException e) { + SimpleLogger.uniqueWarn("Failed to capture Cassandra schema for table " + ref.rawTableName); + } + } + /** * 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 @@ -93,18 +113,22 @@ private static Object handleCqlExecute(String id, Object cqlSession, String quer */ private static TableReference extractTableReference(String query) { if (query == null) { - return new TableReference(null, null); + throw new IllegalArgumentException("query cannot be null"); } Matcher matcher = TABLE_REFERENCE_PATTERN.matcher(query); if (!matcher.find()) { - return new TableReference(null, null); + return new TableReference(null, null, null, null); } else { - String first = stripQuotes(matcher.group("first")); - String second = matcher.group("second") != null ? stripQuotes(matcher.group("second")) : null; + String rawFirst = matcher.group("first"); + String rawSecond = matcher.group("second"); + String first = stripQuotes(rawFirst); + String second = rawSecond != null ? stripQuotes(rawSecond) : 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); + return second != null + ? new TableReference(first, second, rawFirst, rawSecond) + : new TableReference(null, first, null, rawFirst); } } @@ -119,10 +143,19 @@ private static String stripQuotes(String identifier) { private static class TableReference { final String keyspaceName; final String tableName; - - TableReference(String keyspaceName, String tableName) { + /** + * Same keyspace/table, but as the raw, quote-preserving text matched in the CQL string + * (not lower-cased/quote-stripped), needed to resolve the table correctly against the + * driver's own (quote-sensitive) metadata. + */ + final String rawKeyspaceName; + final String rawTableName; + + TableReference(String keyspaceName, String tableName, String rawKeyspaceName, String rawTableName) { this.keyspaceName = keyspaceName; this.tableName = tableName; + this.rawKeyspaceName = rawKeyspaceName; + this.rawTableName = rawTableName; } } diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracer.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracer.java index 2386227c12..259ef9fa9e 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracer.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracer.java @@ -1,8 +1,9 @@ package org.evomaster.client.java.instrumentation.staticstate; +import org.evomaster.client.java.distance.heuristics.Truthness; import org.evomaster.client.java.instrumentation.*; +import org.evomaster.client.java.instrumentation.cassandra.CassandraTableMetadata; import org.evomaster.client.java.instrumentation.heuristic.HeuristicsForJumps; -import org.evomaster.client.java.distance.heuristics.Truthness; import org.evomaster.client.java.instrumentation.shared.*; import java.util.*; @@ -478,6 +479,11 @@ public static void addMongoCollectionType(MongoCollectionSchema mongoCollectionS } } + public static void addCassandraTableMetadata(CassandraTableMetadata cassandraTableMetadata){ + if (!executingInitCassandra) { + getCurrentAdditionalInfo().addCassandraTableMetadata(cassandraTableMetadata); + } + } public static void markLastExecutedStatement(String lastLine, String lastMethod) { getCurrentAdditionalInfo().pushLastExecutedStatement(lastLine, lastMethod); diff --git a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/cassandra/CassandraSchemaTracerTest.java b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/cassandra/CassandraSchemaTracerTest.java new file mode 100644 index 0000000000..b15673dd2d --- /dev/null +++ b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/cassandra/CassandraSchemaTracerTest.java @@ -0,0 +1,237 @@ +package org.evomaster.client.java.instrumentation.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import org.evomaster.client.java.instrumentation.AdditionalInfo; +import org.evomaster.client.java.instrumentation.staticstate.ExecutionTracer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class CassandraSchemaTracerTest { + + private static CqlSession cqlSession; + + private static final int CASSANDRA_PORT = 9042; + private static final String CASSANDRA_IMAGE = "cassandra"; + private static final String CASSANDRA_VERSION = "4.1"; + + private static final GenericContainer cassandra = new GenericContainer<>(CASSANDRA_IMAGE + ":" + CASSANDRA_VERSION) + .withExposedPorts(CASSANDRA_PORT) + .waitingFor(Wait.forLogMessage(".*Starting listening for CQL clients.*", 1) + .withStartupTimeout(Duration.ofMinutes(2))); + + private static final String HOST_NAME = "localhost"; + private static final String DATA_CENTER = "datacenter1"; + + private static final String KEYSPACE_1 = "schema_repo_ks1"; + private static final String KEYSPACE_2 = "schema_repo_ks2"; + + @BeforeAll + static void startCassandra() { + cassandra.start(); + + InetSocketAddress contactPoint = + new InetSocketAddress(HOST_NAME, cassandra.getMappedPort(CASSANDRA_PORT)); + + cqlSession = CqlSession.builder() + .addContactPoint(contactPoint) + .withLocalDatacenter(DATA_CENTER) + .build(); + + cqlSession.execute("CREATE KEYSPACE IF NOT EXISTS " + KEYSPACE_1 + + " WITH replication = {'class':'SimpleStrategy','replication_factor':1}"); + cqlSession.execute("CREATE KEYSPACE IF NOT EXISTS " + KEYSPACE_2 + + " WITH replication = {'class':'SimpleStrategy','replication_factor':1}"); + + // simple single-column primary key + cqlSession.execute("CREATE TABLE IF NOT EXISTS " + KEYSPACE_1 + ".table_a" + + " (id uuid PRIMARY KEY, name text)"); + + // composite partition key + clustering column, to exercise PK/clustering tagging + cqlSession.execute("CREATE TABLE IF NOT EXISTS " + KEYSPACE_2 + ".table_b" + + " (part1 int, part2 int, rank int, payload text, PRIMARY KEY((part1, part2), rank))"); + + // a quoted, mixed-case keyspace/table with a different shape than any lowercase twin + cqlSession.execute("CREATE KEYSPACE IF NOT EXISTS \"" + "SchemaRepoMixedCaseKs" + "\"" + + " WITH replication = {'class':'SimpleStrategy','replication_factor':1}"); + cqlSession.execute("CREATE TABLE IF NOT EXISTS \"SchemaRepoMixedCaseKs\".\"MixedCaseTable\"" + + " (id int PRIMARY KEY, amount int)"); + // a lowercase twin, with a deliberately different column set + cqlSession.execute("CREATE TABLE IF NOT EXISTS " + KEYSPACE_1 + ".mixedcasetable" + + " (id int PRIMARY KEY, unrelated_column text)"); + } + + @AfterAll + static void cleanup() { + if (cqlSession != null) { + cqlSession.close(); + } + ExecutionTracer.reset(); + } + + @BeforeEach + void resetTracer() { + ExecutionTracer.reset(); + } + + private static CassandraColumnMetadata columnNamed(CassandraTableMetadata table, String name) { + return table.getColumns().stream() + .filter(c -> c.getName().equals(name)) + .findFirst() + .orElseThrow(() -> new AssertionError("No column named " + name)); + } + + @Test + void resolve_capturesTablesAcrossMultipleKeyspaces() { + CassandraTableMetadata tableA = CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_1, "table_a"); + CassandraTableMetadata tableB = CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_2, "table_b"); + + assertNotNull(tableA); + assertEquals(KEYSPACE_1, tableA.getKeyspaceName()); + assertEquals("table_a", tableA.getTableName()); + + assertNotNull(tableB); + assertEquals(KEYSPACE_2, tableB.getKeyspaceName()); + assertEquals("table_b", tableB.getTableName()); + } + + @Test + void resolve_taggedPartitionKeyAndClusteringColumns() { + CassandraTableMetadata tableB = CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_2, "table_b"); + assertNotNull(tableB); + + assertTrue(columnNamed(tableB, "part1").isPartitionKey()); + assertTrue(columnNamed(tableB, "part2").isPartitionKey()); + assertFalse(columnNamed(tableB, "part1").isClusteringColumn()); + + assertTrue(columnNamed(tableB, "rank").isClusteringColumn()); + assertFalse(columnNamed(tableB, "rank").isPartitionKey()); + + CassandraColumnMetadata payload = columnNamed(tableB, "payload"); + assertFalse(payload.isPartitionKey()); + assertFalse(payload.isClusteringColumn()); + assertEquals("text", payload.getCqlType()); + } + + @Test + void resolve_unseenTable_capturedOnDemandWithoutError() { + cqlSession.execute("CREATE TABLE IF NOT EXISTS " + KEYSPACE_1 + ".table_created_after_snapshot" + + " (id uuid PRIMARY KEY, age int)"); + + CassandraTableMetadata table = CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_1, "table_created_after_snapshot"); + + assertNotNull(table); + assertEquals("table_created_after_snapshot", table.getTableName()); + assertTrue(columnNamed(table, "id").isPartitionKey()); + assertEquals("int", columnNamed(table, "age").getCqlType()); + } + + @Test + void resolve_unqualifiedReference_usesSessionCurrentKeyspace() { + InetSocketAddress contactPoint = + new InetSocketAddress(HOST_NAME, cassandra.getMappedPort(CASSANDRA_PORT)); + try (CqlSession sessionWithDefaultKeyspace = CqlSession.builder() + .addContactPoint(contactPoint) + .withLocalDatacenter(DATA_CENTER) + .withKeyspace(KEYSPACE_1) + .build()) { + + CassandraTableMetadata table = CassandraSchemaTracer.resolve(sessionWithDefaultKeyspace, null, "table_a"); + + assertNotNull(table); + assertEquals(KEYSPACE_1, table.getKeyspaceName()); + assertEquals("table_a", table.getTableName()); + } + } + + @Test + void resolve_unqualifiedReference_noCurrentKeyspace_returnsNull() { + assertFalse(cqlSession.getKeyspace().isPresent()); + + CassandraTableMetadata table = CassandraSchemaTracer.resolve(cqlSession, null, "table_a"); + + assertNull(table); + } + + @Test + void resolve_nonexistentTable_returnsNull() { + CassandraTableMetadata table = CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_1, "no_such_table"); + + assertNull(table); + } + + @Test + void resolve_nonexistentKeyspace_returnsNull() { + CassandraTableMetadata table = CassandraSchemaTracer.resolve(cqlSession, "no_such_keyspace", "table_a"); + + assertNull(table); + } + + @Test + void resolve_quotedMixedCaseKeyspaceAndTable_targetsCorrectOne() { + CassandraTableMetadata mixedCase = CassandraSchemaTracer.resolve( + cqlSession, "\"SchemaRepoMixedCaseKs\"", "\"MixedCaseTable\""); + + assertNotNull(mixedCase); + assertEquals("SchemaRepoMixedCaseKs", mixedCase.getKeyspaceName()); + assertEquals("MixedCaseTable", mixedCase.getTableName()); + assertNotNull(columnNamed(mixedCase, "amount")); + + // the lowercase, unquoted twin in a different keyspace must not be conflated with it + CassandraTableMetadata lowercaseTwin = CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_1, "mixedcasetable"); + assertNotNull(lowercaseTwin); + assertNotNull(columnNamed(lowercaseTwin, "unrelated_column")); + } + + @Test + void resolve_calledTwiceForSameTable_returnsEqualMetadata() { + CassandraTableMetadata first = CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_1, "table_a"); + CassandraTableMetadata second = CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_1, "table_a"); + + assertNotNull(first); + assertEquals(first, second); + } + + @Test + void resolve_firstTimeSeen_tracesSchemaViaExecutionTracer() { + cqlSession.execute("CREATE TABLE IF NOT EXISTS " + KEYSPACE_1 + ".trace_once_table" + + " (id uuid PRIMARY KEY, age int)"); + + CassandraTableMetadata table = CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_1, "trace_once_table"); + assertNotNull(table); + + List additionalInfoList = ExecutionTracer.exposeAdditionalInfoList(); + assertEquals(1, additionalInfoList.size()); + + Set traced = additionalInfoList.get(0).getCassandraTableMetadataData(); + assertEquals(1, traced.size()); + assertEquals(table, traced.iterator().next()); + } + + @Test + void resolve_alreadyCached_doesNotRetraceSchema() { + cqlSession.execute("CREATE TABLE IF NOT EXISTS " + KEYSPACE_1 + ".trace_no_duplicate_table" + + " (id uuid PRIMARY KEY, age int)"); + + // first call: not yet cached, traces the schema + CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_1, "trace_no_duplicate_table"); + ExecutionTracer.reset(); + + // second call: already cached, must not trace again + CassandraSchemaTracer.resolve(cqlSession, KEYSPACE_1, "trace_no_duplicate_table"); + + List additionalInfoList = ExecutionTracer.exposeAdditionalInfoList(); + assertEquals(1, additionalInfoList.size()); + assertTrue(additionalInfoList.get(0).getCassandraTableMetadataData().isEmpty()); + } +} \ No newline at end of file