This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository 
https://gitbox.apache.org/repos/asf/pekko-persistence-cassandra.git


The following commit(s) were added to refs/heads/main by this push:
     new 7c1d4e0  validate keyspace and tablenames (#476)
7c1d4e0 is described below

commit 7c1d4e0a23523aad4c4e5742ec42052ff18f5a49
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Aug 24 10:45:01 2026 +0100

    validate keyspace and tablenames (#476)
    
    * validate keyspace and tablenames
    
    * update validations
    
    * fix identifier validation regex to match CQL grammar
    
    Motivation:
    The identifier validation added in this branch is now enforced at settings
    initialisation, so `keyspaceNameRegex` has to agree with what Cassandra
    actually accepts. The unquoted alternative had been relaxed to
    `[a-zA-Z_][a-zA-Z0-9_]{0,47}`, which accepts a leading underscore. CQL's
    unquoted identifier grammar is `LETTER (LETTER | DIGIT | '_')*`, so names
    such as `_test_123` and `_` were accepted by the validator and then
    rejected by Cassandra at DDL time, replacing a clear startup error with a
    later CQL failure.
    
    The table name test also called `validateKeyspaceName`, so
    `validateTableName` had no coverage even though it is now on the startup
    path.
    
    Modification:
    - Restrict the unquoted alternative back to a leading letter:
      `^([a-zA-Z][a-zA-Z0-9_]{0,47}|"[^"]{1,48}")$`.
    - Keep the relaxed quoted alternative, which is required so that existing
      deployments using quoted, case-sensitive identifiers are not rejected
      now that validation runs at startup.
    - Expand the error messages to state the leading-letter rule and the
      quoted alternative.
    - Correct the `_test_123` and `_` expectations in the validation table.
    - Make the table name test exercise `validateTableName` and assert the
      message identifies a table.
    - Add tests asserting quoted identifiers survive validation in
      JournalSettings, SnapshotSettings and EventsByTagSettings.
    
    Result:
    The validator accepts exactly the identifiers Cassandra accepts. Invalid
    names still fail fast at startup, and quoted identifiers continue to work.
    
    Tests:
    - sbt "core/testOnly 
org.apache.pekko.persistence.cassandra.CassandraPluginSettingsSpec" - 21 passed
    - sbt +mimaReportBinaryIssues - no issues
    - sbt "+core/Test/compile" - Scala 2.13.18 and 3.3.8 compile
    - sbt scalafmtAll scalafmtSbt headerCreateAll - clean
    - Cassandra integration tests - Not run, no Cassandra cluster available
    
    References:
    Refs #476
---
 .../cassandra/EventsByTagSettings.scala            |   3 +-
 .../persistence/cassandra/PluginSettings.scala     |  13 ++-
 .../cassandra/journal/JournalSettings.scala        |  10 +-
 .../cassandra/snapshot/SnapshotSettings.scala      |   6 +-
 .../cassandra/CassandraPluginSettingsSpec.scala    | 125 +++++++++++++++++++--
 5 files changed, 138 insertions(+), 19 deletions(-)

diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/EventsByTagSettings.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/EventsByTagSettings.scala
index be34c13..f02fd06 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/EventsByTagSettings.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/EventsByTagSettings.scala
@@ -23,6 +23,7 @@ import org.apache.pekko
 import pekko.actor.ActorSystem
 import pekko.annotation.InternalApi
 import pekko.event.Logging
+import pekko.persistence.cassandra.PluginSettings.validateTableName
 import pekko.persistence.cassandra.compaction.CassandraCompactionStrategy
 import pekko.persistence.cassandra.journal.TagWriter.TagWriterSettings
 import pekko.persistence.cassandra.journal.TimeBucket
@@ -137,7 +138,7 @@ import com.typesafe.config.Config
   }
 
   val tagTable = TableSettings(
-    eventsByTagConfig.getString("table"),
+    validateTableName(eventsByTagConfig.getString("table")),
     
CassandraCompactionStrategy(eventsByTagConfig.getConfig("compaction-strategy")),
     eventsByTagConfig.getLong("gc-grace-seconds"),
     if (eventsByTagConfig.hasPath("time-to-live"))
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/PluginSettings.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/PluginSettings.scala
index 5c08fca..f2e150f 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/PluginSettings.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/PluginSettings.scala
@@ -55,8 +55,11 @@ import com.typesafe.config.Config
   def apply(system: ActorSystem, config: Config): PluginSettings =
     new PluginSettings(system, config)
 
+  // An unquoted CQL identifier must start with a letter and may contain 
letters, digits and
+  // underscores. A quoted identifier is case sensitive and may contain any 
character except the
+  // double quote itself. Either form is limited to 48 characters.
   val keyspaceNameRegex =
-    """^("[a-zA-Z]{1}[\w]{0,47}"|[a-zA-Z]{1}[\w]{0,47})$"""
+    """^([a-zA-Z][a-zA-Z0-9_]{0,47}|"[^"]{1,48}")$"""
 
   /**
    * Builds replication strategy command to create a keyspace.
@@ -107,7 +110,9 @@ import com.typesafe.config.Config
       keyspaceName
     } else {
       throw new IllegalArgumentException(
-        s"Invalid keyspace name. A keyspace may have 32 or fewer alpha-numeric 
characters and underscores. Value was: $keyspaceName")
+        s"Invalid keyspace name. An unquoted keyspace name must start with a 
letter and may have 48 or fewer " +
+        s"alpha-numeric characters and underscores. Alternatively it may be a 
double-quoted identifier of 1 to 48 " +
+        s"characters. Value was: $keyspaceName")
     }
 
   /**
@@ -122,6 +127,8 @@ import com.typesafe.config.Config
       tableName
     } else {
       throw new IllegalArgumentException(
-        s"Invalid table name. A table name may have 32 or fewer alpha-numeric 
characters and underscores. Value was: $tableName")
+        s"Invalid table name. An unquoted table name must start with a letter 
and may have 48 or fewer " +
+        s"alpha-numeric characters and underscores. Alternatively it may be a 
double-quoted identifier of 1 to 48 " +
+        s"characters. Value was: $tableName")
     }
 }
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/JournalSettings.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/JournalSettings.scala
index 6caaf91..31330dd 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/JournalSettings.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/JournalSettings.scala
@@ -19,6 +19,8 @@ import pekko.actor.NoSerializationVerificationNeeded
 import pekko.annotation.InternalApi
 import pekko.annotation.InternalStableApi
 import pekko.persistence.cassandra.PluginSettings.getReplicationStrategy
+import pekko.persistence.cassandra.PluginSettings.validateKeyspaceName
+import pekko.persistence.cassandra.PluginSettings.validateTableName
 import pekko.persistence.cassandra.compaction.CassandraCompactionStrategy
 import pekko.persistence.cassandra.getListFromConfig
 import com.typesafe.config.Config
@@ -36,11 +38,11 @@ import com.typesafe.config.Config
   val keyspaceAutoCreate: Boolean = 
journalConfig.getBoolean("keyspace-autocreate")
   val tablesAutoCreate: Boolean = journalConfig.getBoolean("tables-autocreate")
 
-  val keyspace: String = journalConfig.getString("keyspace")
+  val keyspace: String = 
validateKeyspaceName(journalConfig.getString("keyspace"))
 
-  val table: String = journalConfig.getString("table")
-  val metadataTable: String = journalConfig.getString("metadata-table")
-  val allPersistenceIdsTable: String = 
journalConfig.getString("all-persistence-ids-table")
+  val table: String = validateTableName(journalConfig.getString("table"))
+  val metadataTable: String = 
validateTableName(journalConfig.getString("metadata-table"))
+  val allPersistenceIdsTable: String = 
validateTableName(journalConfig.getString("all-persistence-ids-table"))
 
   val tableCompactionStrategy: CassandraCompactionStrategy =
     
CassandraCompactionStrategy(journalConfig.getConfig("table-compaction-strategy"))
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/snapshot/SnapshotSettings.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/snapshot/SnapshotSettings.scala
index 41950d9..8f8f7ab 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/snapshot/SnapshotSettings.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/snapshot/SnapshotSettings.scala
@@ -17,6 +17,8 @@ import org.apache.pekko
 import pekko.actor.ActorSystem
 import pekko.annotation.InternalApi
 import pekko.persistence.cassandra.PluginSettings.getReplicationStrategy
+import pekko.persistence.cassandra.PluginSettings.validateKeyspaceName
+import pekko.persistence.cassandra.PluginSettings.validateTableName
 import pekko.persistence.cassandra.compaction.CassandraCompactionStrategy
 import pekko.persistence.cassandra.getListFromConfig
 import com.typesafe.config.Config
@@ -31,9 +33,9 @@ import com.typesafe.config.Config
   val keyspaceAutoCreate: Boolean = 
snapshotConfig.getBoolean("keyspace-autocreate")
   val tablesAutoCreate: Boolean = 
snapshotConfig.getBoolean("tables-autocreate")
 
-  val keyspace: String = snapshotConfig.getString("keyspace")
+  val keyspace: String = 
validateKeyspaceName(snapshotConfig.getString("keyspace"))
 
-  val table: String = snapshotConfig.getString("table")
+  val table: String = validateTableName(snapshotConfig.getString("table"))
 
   val tableCompactionStrategy: CassandraCompactionStrategy =
     
CassandraCompactionStrategy(snapshotConfig.getConfig("table-compaction-strategy"))
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/CassandraPluginSettingsSpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/CassandraPluginSettingsSpec.scala
index e155545..ce97bcb 100644
--- 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/CassandraPluginSettingsSpec.scala
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/CassandraPluginSettingsSpec.scala
@@ -24,6 +24,8 @@ import org.scalatest.prop.TableDrivenPropertyChecks._
 import scala.util.Random
 
 import pekko.persistence.cassandra.journal.JournalSettings
+import pekko.persistence.cassandra.snapshot.SnapshotSettings
+import pekko.persistence.cassandra.EventsByTagSettings
 
 class CassandraPluginSettingsSpec
     extends TestKit(ActorSystem("CassandraPluginConfigSpec"))
@@ -38,12 +40,33 @@ class CassandraPluginSettingsSpec
     def maxKey = 
Random.alphanumeric.dropWhile(_.toString.matches("[^a-zA-Z]")).take(48).mkString
 
     Table(
-      ("Keyspace", "isValid"), ("test", true), ("_test_123", false), ("", 
false), ("test-space", false),
-      ("'test'", false), ("a", true), ("a_", true), ("1", false), ("a1", 
true), ("_", false), ("asdf!", false),
-      (maxKey, true), ("\"_asdf\"", false), ("\"_\"", false), ("\"a\"", true), 
("\"a_sdf\"", true), ("\"\"", false),
-      ("\"valid_with_quotes\"", true), ("\"missing_trailing_quote", false), 
("missing_leading_quote\"", false),
-      ('"'.toString + maxKey + '"'.toString, true), // using interpolation 
here breaks scalafmt :-/
-      (maxKey + "_", false))
+      ("Keyspace", "isValid"),
+      // unquoted: must start with a letter, then alphanumeric/underscore, max 
48 chars
+      ("test", true),
+      ("_test_123", false),
+      ("", false),
+      ("test-space", false),
+      ("'test'", false),
+      ("a", true),
+      ("a_", true),
+      ("1", false),
+      ("a1", true),
+      ("_", false),
+      ("asdf!", false),
+      (maxKey, true),
+      (maxKey + "_", false),
+      // quoted: any content except a double quote, 1 to 48 chars
+      ("\"_asdf\"", true),
+      ("\"_\"", true),
+      ("\"a\"", true),
+      ("\"a_sdf\"", true),
+      ("\"\"", false),
+      ("\"valid_with_quotes\"", true),
+      ("\"test-space\"", true),
+      ("\"My Table\"", true),
+      ("\"missing_trailing_quote", false),
+      ("missing_leading_quote\"", false),
+      ('"'.toString + maxKey + '"'.toString, true))
   }
 
   override protected def afterAll(): Unit = {
@@ -124,14 +147,98 @@ class CassandraPluginSettingsSpec
 
     "validate table name parameter" in {
       forAll(keyspaceNames) { (tableName, isValid) =>
-        if (isValid) PluginSettings.validateKeyspaceName(tableName) must 
be(tableName)
+        if (isValid) PluginSettings.validateTableName(tableName) must 
be(tableName)
         else
           intercept[IllegalArgumentException] {
-            PluginSettings.validateKeyspaceName(tableName)
-          }
+            PluginSettings.validateTableName(tableName)
+          }.getMessage must include("Invalid table name")
       }
     }
 
+    "reject invalid keyspace name in JournalSettings" in {
+      val badConfig =
+        ConfigFactory.parseString("""journal.keyspace = 
"invalid;name"""").withFallback(defaultConfig)
+      intercept[IllegalArgumentException] {
+        new JournalSettings(system, badConfig)
+      }.getMessage must include("Invalid keyspace name")
+    }
+
+    "reject invalid table name in JournalSettings" in {
+      val badConfig =
+        ConfigFactory.parseString("""journal.table = 
"invalid;table"""").withFallback(defaultConfig)
+      intercept[IllegalArgumentException] {
+        new JournalSettings(system, badConfig)
+      }.getMessage must include("Invalid table name")
+    }
+
+    "reject invalid metadata table name in JournalSettings" in {
+      val badConfig =
+        ConfigFactory.parseString("""journal.metadata-table = 
"bad-name"""").withFallback(defaultConfig)
+      intercept[IllegalArgumentException] {
+        new JournalSettings(system, badConfig)
+      }.getMessage must include("Invalid table name")
+    }
+
+    "reject invalid all-persistence-ids table name in JournalSettings" in {
+      val badConfig =
+        ConfigFactory.parseString("""journal.all-persistence-ids-table = 
"bad-name"""").withFallback(defaultConfig)
+      intercept[IllegalArgumentException] {
+        new JournalSettings(system, badConfig)
+      }.getMessage must include("Invalid table name")
+    }
+
+    "reject invalid keyspace name in SnapshotSettings" in {
+      val badConfig =
+        ConfigFactory.parseString("""snapshot.keyspace = 
"invalid;name"""").withFallback(defaultConfig)
+      intercept[IllegalArgumentException] {
+        new SnapshotSettings(system, badConfig)
+      }.getMessage must include("Invalid keyspace name")
+    }
+
+    "reject invalid table name in SnapshotSettings" in {
+      val badConfig =
+        ConfigFactory.parseString("""snapshot.table = 
"invalid;table"""").withFallback(defaultConfig)
+      intercept[IllegalArgumentException] {
+        new SnapshotSettings(system, badConfig)
+      }.getMessage must include("Invalid table name")
+    }
+
+    "reject invalid tag table name in EventsByTagSettings" in {
+      val badConfig =
+        ConfigFactory.parseString("""events-by-tag.table = 
"bad-name"""").withFallback(defaultConfig)
+      intercept[IllegalArgumentException] {
+        new EventsByTagSettings(system, badConfig)
+      }.getMessage must include("Invalid table name")
+    }
+
+    // Quoted identifiers are case sensitive in CQL and may contain characters 
that are not
+    // allowed unquoted, so they must survive validation now that it is 
applied at startup.
+    "accept quoted keyspace and table names in JournalSettings" in {
+      val quotedConfig = ConfigFactory.parseString("""
+          |journal.keyspace = "\"My Keyspace\""
+          |journal.table = "\"my-messages\""
+        """.stripMargin).withFallback(defaultConfig)
+      val config = new JournalSettings(system, quotedConfig)
+      config.keyspace must be("\"My Keyspace\"")
+      config.table must be("\"my-messages\"")
+    }
+
+    "accept a quoted table name in SnapshotSettings" in {
+      val quotedConfig = ConfigFactory.parseString("""
+          |snapshot.table = "\"my-snapshots\""
+        """.stripMargin).withFallback(defaultConfig)
+      val config = new SnapshotSettings(system, quotedConfig)
+      config.table must be("\"my-snapshots\"")
+    }
+
+    "accept a quoted tag table name in EventsByTagSettings" in {
+      val quotedConfig = ConfigFactory.parseString("""
+          |events-by-tag.table = "\"my-tag-views\""
+        """.stripMargin).withFallback(defaultConfig)
+      val config = new EventsByTagSettings(system, quotedConfig)
+      config.tagTable.name must be("\"my-tag-views\"")
+    }
+
     "parse keyspace-autocreate parameter" in {
       val configWithFalseKeyspaceAutocreate =
         ConfigFactory.parseString("journal.keyspace-autocreate = 
false").withFallback(defaultConfig)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to