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]