This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch cassandra-6.0
in repository https://gitbox.apache.org/repos/asf/cassandra.git
The following commit(s) were added to refs/heads/cassandra-6.0 by this push:
new 96eb25efb1 Add LIKE query support to property-based testing
96eb25efb1 is described below
commit 96eb25efb1973238994bb69890ac7773bd3ea094
Author: Sunil Ramchandra Pawar <[email protected]>
AuthorDate: Mon Mar 16 14:47:05 2026 +0530
Add LIKE query support to property-based testing
patch by Sunil Ramchandra Pawar; reviewed by Caleb Rackliffe and David
Capwell for CASSANDRA-21068
---
CHANGES.txt | 1 +
.../cassandra/pages/developing/cql/dml.adoc | 6 ++
.../cassandra/cql3/restrictions/LikePattern.java | 3 +
.../test/cql3/SingleNodeTableWalkTest.java | 72 +++++++++++++++++++++
.../cassandra/harry/model/ASTSingleTableModel.java | 72 ++++++++++++++++++++-
.../org/apache/cassandra/cql3/ast/Conditional.java | 50 +++++++++++++++
.../unit/org/apache/cassandra/cql3/ast/Select.java | 7 ++
.../cql3/restrictions/LikePatternTest.java | 75 ++++++++++++++++++++++
8 files changed, 284 insertions(+), 2 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index afd30f48bc..2c3fd9d2bb 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
6.0-alpha3
+ * Reject LIKE patterns with a wildcard (%) anywhere other than the start or
end (CASSANDRA-21068)
* Stabilize cursor-based compaction (CASSANDRA-21462)
* Stop using Java streams in CIDRPermissions.SubsetPermissions#canAccessFrom
hot method (CASSANDRA-21588)
* Enable trickle fsync by default (CASSANDRA-21572)
diff --git a/doc/modules/cassandra/pages/developing/cql/dml.adoc
b/doc/modules/cassandra/pages/developing/cql/dml.adoc
index 4f13ba2b0c..ad01899627 100644
--- a/doc/modules/cassandra/pages/developing/cql/dml.adoc
+++ b/doc/modules/cassandra/pages/developing/cql/dml.adoc
@@ -168,6 +168,12 @@ sets, and maps). In the case of maps, `CONTAINS` applies
to the map
values. The `CONTAINS KEY` operator may only be used on map columns and
applies to the map keys.
+The `LIKE` operator performs a pattern match on `text` and `ascii` columns,
using `%`
+as a wildcard. A `%` is only interpreted as a wildcard at the start and/or end
of the
+pattern: `'foo%'` (prefix), `'%foo'` (suffix), `'%foo%'` (contains), or
`'foo'` (exact
+match). A `%` anywhere else in the pattern is rejected. CQL does not support
interior
+wildcards, and there is no escape sequence for matching a literal `%`.
+
[[group-by-clause]]
=== Grouping results
diff --git a/src/java/org/apache/cassandra/cql3/restrictions/LikePattern.java
b/src/java/org/apache/cassandra/cql3/restrictions/LikePattern.java
index f5ea1e45f4..fdedff5458 100644
--- a/src/java/org/apache/cassandra/cql3/restrictions/LikePattern.java
+++ b/src/java/org/apache/cassandra/cql3/restrictions/LikePattern.java
@@ -112,6 +112,9 @@ public final class LikePattern
ByteBuffer newValue = value.duplicate();
newValue.position(beginIndex);
newValue.limit(endIndex);
+ // Checking if we still have WILDCARD in value and if yes return error.
+ if (ByteBufferUtil.contains(newValue, WILDCARD))
+ throw invalidRequest("LIKE value can't contain a % other than at
the start and/or end");
return new LikePattern(kind, newValue);
}
}
diff --git
a/test/distributed/org/apache/cassandra/distributed/test/cql3/SingleNodeTableWalkTest.java
b/test/distributed/org/apache/cassandra/distributed/test/cql3/SingleNodeTableWalkTest.java
index dc71e8f96f..adba049f90 100644
---
a/test/distributed/org/apache/cassandra/distributed/test/cql3/SingleNodeTableWalkTest.java
+++
b/test/distributed/org/apache/cassandra/distributed/test/cql3/SingleNodeTableWalkTest.java
@@ -55,9 +55,11 @@ import org.apache.cassandra.cql3.ast.Select;
import org.apache.cassandra.cql3.ast.Symbol;
import org.apache.cassandra.cql3.ast.TableReference;
import org.apache.cassandra.cql3.ast.Value;
+import org.apache.cassandra.cql3.restrictions.LikePattern;
import org.apache.cassandra.db.Clustering;
import org.apache.cassandra.db.marshal.AbstractType;
import org.apache.cassandra.db.marshal.InetAddressType;
+import org.apache.cassandra.db.marshal.StringType;
import org.apache.cassandra.db.marshal.UTF8Type;
import org.apache.cassandra.dht.Murmur3Partitioner;
import org.apache.cassandra.distributed.Cluster;
@@ -421,6 +423,58 @@ public class SingleNodeTableWalkTest extends
StatefulASTBase
return state.command(rs, select, annotation);
}
+ public Property.Command<State, Void, ?> likeQuery(RandomSource rs, State
state)
+ {
+ Symbol symbol = rs.pick(state.searchableTextColumns);
+ TreeMap<ByteBuffer, List<BytesPartitionState.PrimaryKey>> universe =
state.model.index(symbol);
+
+ NavigableSet<ByteBuffer> allowed =
Sets.filter(universe.navigableKeySet(),
+ b -> {
+ String s =
symbol.type().getString(b);
+ return
!s.contains("%") && !ByteBufferUtil.EMPTY_BYTE_BUFFER.equals(b);
+ });
+
+ if (allowed.isEmpty())
+ return Property.ignoreCommand();
+
+ ByteBuffer value = rs.pickOrderedSet(allowed);
+ String valueStr = symbol.type().getString(value);
+ String pattern;
+
+ LikePattern.Kind kind = rs.pick(LikePattern.Kind.values());
+ switch (kind)
+ {
+ case PREFIX:
+ int prefixLen = rs.nextInt(1, valueStr.length() + 1);
+ pattern = valueStr.substring(0, prefixLen) + "%";
+ break;
+
+ case SUFFIX:
+ int suffixLen = rs.nextInt(1, valueStr.length() + 1);
+ pattern = "%" + valueStr.substring(valueStr.length() -
suffixLen);
+ break;
+
+ case CONTAINS:
+ int begin = rs.nextInt(0, valueStr.length());
+ int end = rs.nextInt(begin + 1, valueStr.length() + 1);
+ pattern = "%" + valueStr.substring(begin, end) + "%";
+ break;
+
+ case MATCHES:
+ pattern = valueStr;
+ break;
+
+ default:
+ throw new IllegalStateException("Unhandled LIKE kind: " +
kind);
+ }
+
+ Select.Builder builder = Select.builder().table(state.metadata);
+ builder.allowFiltering();
+ builder.like(symbol, new Bind(pattern, symbol.type()));
+ Select select = builder.build();
+ return state.command(rs, select, "LIKE query on " +
symbol.detailedName());
+ }
+
protected State createState(RandomSource rs, Cluster cluster)
{
return new State(rs, cluster);
@@ -456,6 +510,7 @@ public class SingleNodeTableWalkTest extends StatefulASTBase
.addIf(State::allowNonPartitionMultiColumnQuery, this::multiColumnQuery)
.addIf(State::allowPartitionQuery,
this::partitionRestrictedQuery)
.addIf(State::allowClusteringBetweenQuery,
this::clusteringBetweenQuery)
+ .addIf(State::allowLikeQuery,
this::likeQuery)
.destroyState(State::close)
.commandsTransformer(LoggingCommand.factory())
.onSuccess(onSuccess(logger))
@@ -510,6 +565,7 @@ public class SingleNodeTableWalkTest extends StatefulASTBase
private final List<Symbol> searchableNonPartitionColumns;
private final List<Symbol> searchableColumns;
private final List<Symbol> nonPkIndexedColumns;
+ private final List<Symbol> searchableTextColumns;
public State(RandomSource rs, Cluster cluster)
{
@@ -577,6 +633,11 @@ public class SingleNodeTableWalkTest extends
StatefulASTBase
.stream()
.filter(this::isSearchable)
.collect(Collectors.toList());
+
+ searchableTextColumns = model.factory.selectionOrder
+ .stream()
+ .filter(this::supportsLike)
+ .collect(Collectors.toList());
}
@Override
@@ -598,6 +659,12 @@ public class SingleNodeTableWalkTest extends
StatefulASTBase
return !(symbol.type().isMultiCell() &&
(symbol.type().isCollection() || symbol.type().isUDT()));
}
+ private boolean supportsLike(Symbol symbol)
+ {
+ AbstractType<?> type = symbol.type();
+ return type instanceof StringType;
+ }
+
@Override
protected Gen<Mutation> mutationGen()
{
@@ -646,6 +713,11 @@ public class SingleNodeTableWalkTest extends
StatefulASTBase
return indexed;
}
+ public boolean allowLikeQuery()
+ {
+ return !model.isEmpty() && !searchableTextColumns.isEmpty();
+ }
+
public boolean supportTokens()
{
return hasPartitions();
diff --git
a/test/harry/main/org/apache/cassandra/harry/model/ASTSingleTableModel.java
b/test/harry/main/org/apache/cassandra/harry/model/ASTSingleTableModel.java
index 58cfa4905a..b3835c299c 100644
--- a/test/harry/main/org/apache/cassandra/harry/model/ASTSingleTableModel.java
+++ b/test/harry/main/org/apache/cassandra/harry/model/ASTSingleTableModel.java
@@ -38,6 +38,7 @@ import java.util.TreeMap;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.IntFunction;
+import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -2255,6 +2256,7 @@ public class ASTSingleTableModel
{
private final Map<ReferenceExpression, List<? extends Expression>> eq
= new HashMap<>();
private final Map<ReferenceExpression, List<ColumnCondition>> ltOrGt =
new HashMap<>();
+ private final Map<ReferenceExpression, LikeCondition> like = new
HashMap<>();
@Nullable
private Token token = null;
@Nullable
@@ -2474,6 +2476,20 @@ public class ASTSingleTableModel
addConditional(and.left);
addConditional(and.right);
}
+ else if (conditional instanceof Conditional.Like)
+ {
+ Conditional.Like likeCondition = (Conditional.Like)
conditional;
+ if (likeCondition.ref instanceof Symbol)
+ {
+ Symbol col = (Symbol) likeCondition.ref;
+ ByteBuffer pattern = eval(likeCondition.pattern);
+ var override = like.put(col, new LikeCondition(col.type(),
pattern));
+ if (override != null)
+ throw new IllegalStateException("Column " +
col.detailedName() + " had 2 LIKE statements...");
+ }
+ else
+ throw new
UnsupportedOperationException(likeCondition.ref.getClass().getCanonicalName());
+ }
else
{
//TODO (coverage): IS
@@ -2526,6 +2542,14 @@ public class ASTSingleTableModel
if (!matches(col.type(), actual, ltOrGt.get(col)))
return false;
}
+ if (like.containsKey(col))
+ {
+ ByteBuffer actual = accessor.apply(columns.indexOf(col));
+ if (actual == null)
+ return false;
+ if (!like.get(col).matches(actual))
+ return false;
+ }
}
return true;
}
@@ -2533,13 +2557,15 @@ public class ASTSingleTableModel
private boolean testsClustering()
{
return factory.clusteringColumns.stream().anyMatch(eq::containsKey)
- ||
factory.clusteringColumns.stream().anyMatch(ltOrGt::containsKey);
+ ||
factory.clusteringColumns.stream().anyMatch(ltOrGt::containsKey)
+ ||
factory.clusteringColumns.stream().anyMatch(like::containsKey);
}
private boolean testsRegular()
{
return factory.regularColumns.stream().anyMatch(eq::containsKey)
- ||
factory.regularColumns.stream().anyMatch(ltOrGt::containsKey);
+ ||
factory.regularColumns.stream().anyMatch(ltOrGt::containsKey)
+ ||
factory.regularColumns.stream().anyMatch(like::containsKey);
}
private boolean testsRow()
@@ -2572,6 +2598,48 @@ public class ASTSingleTableModel
}
}
+ private static class LikeCondition
+ {
+ private final AbstractType<?> type;
+ private final Predicate<String> fn;
+
+ private LikeCondition(AbstractType<?> type, ByteBuffer pattern)
+ {
+ this.type = type;
+ String patternStr = type.getString(pattern);
+
+ // Guard against empty or all-wildcard patterns ("", "%", "%%")
that would leave an empty search value.
+ if (patternStr.isEmpty() || (patternStr.startsWith("%") &&
patternStr.endsWith("%") && patternStr.length() <= 2))
+ throw new AssertionError("LIKE value can't be empty or just %:
" + patternStr);
+
+ if (patternStr.startsWith("%") && patternStr.endsWith("%"))
+ {
+ String substring = patternStr.substring(1, patternStr.length()
- 1);
+ this.fn = v -> v.contains(substring);
+ }
+ else if (patternStr.startsWith("%"))
+ {
+ String suffix = patternStr.substring(1);
+ this.fn = v -> v.endsWith(suffix);
+ }
+ else if (patternStr.endsWith("%"))
+ {
+ String prefix = patternStr.substring(0, patternStr.length() -
1);
+ this.fn = v -> v.startsWith(prefix);
+ }
+ else
+ {
+ this.fn = v -> v.equals(patternStr);
+ }
+ }
+
+ private boolean matches(ByteBuffer value)
+ {
+ String valueStr = type.getString(value);
+ return fn.test(valueStr);
+ }
+ }
+
private interface ColumnUpdate
{
void update(long nowTs, Map<Symbol, ByteBuffer> write);
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Conditional.java
b/test/unit/org/apache/cassandra/cql3/ast/Conditional.java
index c7323127f6..2246ad0b3e 100644
--- a/test/unit/org/apache/cassandra/cql3/ast/Conditional.java
+++ b/test/unit/org/apache/cassandra/cql3/ast/Conditional.java
@@ -279,6 +279,45 @@ public interface Conditional extends Expression
}
}
+ class Like implements Conditional
+ {
+ public final ReferenceExpression ref;
+ public final Expression pattern;
+
+ public Like(ReferenceExpression ref, Expression pattern)
+ {
+ this.ref = ref;
+ this.pattern = pattern;
+ }
+
+ @Override
+ public void toCQL(StringBuilder sb, CQLFormatter formatter)
+ {
+ ref.toCQL(sb, formatter);
+ sb.append(" LIKE ");
+ pattern.toCQL(sb, formatter);
+ }
+
+ @Override
+ public Stream<? extends Element> stream()
+ {
+ return Stream.of(ref, pattern);
+ }
+
+ @Override
+ public Conditional visit(Visitor v)
+ {
+ var u = v.visit(this);
+ if (u != this) return u;
+ var ref = this.ref.visit(v);
+ var pattern = this.pattern.visit(v);
+ if (ref == this.ref && pattern == this.pattern)
+ return this;
+
+ return new Like(ref, pattern);
+ }
+ }
+
class And implements Conditional
{
public final Conditional left, right;
@@ -412,6 +451,11 @@ public interface Conditional extends Expression
T is(ReferenceExpression ref, Is.Kind kind);
+ default T like(ReferenceExpression ref, Expression pattern)
+ {
+ throw new UnsupportedOperationException("LIKE is only supported in
SELECT");
+ }
+
@Override
default T value(ReferenceExpression symbol, Expression e)
{
@@ -488,6 +532,12 @@ public interface Conditional extends Expression
return add(new Is(ref, kind));
}
+ @Override
+ public Builder like(ReferenceExpression ref, Expression pattern)
+ {
+ return add(new Like(ref, pattern));
+ }
+
public Builder is(String ref, Is.Kind kind)
{
return is(Symbol.unknownType(ref), kind);
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Select.java
b/test/unit/org/apache/cassandra/cql3/ast/Select.java
index 7e8eccf06c..deef046e37 100644
--- a/test/unit/org/apache/cassandra/cql3/ast/Select.java
+++ b/test/unit/org/apache/cassandra/cql3/ast/Select.java
@@ -415,6 +415,13 @@ FROM [keyspace_name.] table_name
return (T) this;
}
+ @Override
+ public T like(ReferenceExpression ref, Expression pattern)
+ {
+ where.like(ref, pattern);
+ return (T) this;
+ }
+
public T orderByColumn(String name, AbstractType<?> type,
OrderBy.Ordering ordering)
{
return orderByColumn(new Symbol(name, type), ordering);
diff --git
a/test/unit/org/apache/cassandra/cql3/restrictions/LikePatternTest.java
b/test/unit/org/apache/cassandra/cql3/restrictions/LikePatternTest.java
new file mode 100644
index 0000000000..50977ba45f
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/restrictions/LikePatternTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.cql3.restrictions;
+
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.exceptions.InvalidRequestException;
+import org.apache.cassandra.utils.ByteBufferUtil;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class LikePatternTest
+{
+ @BeforeClass
+ public static void setupDD()
+ {
+ DatabaseDescriptor.daemonInitialization();
+ }
+
+ @Test
+ public void rejectsInteriorWildcard()
+ {
+ for (String pattern : new String[]{ "foo%bar", "%foo%bar", "foo%bar%",
"%foo%bar%" })
+ assertRejected(pattern);
+ }
+
+ @Test
+ public void acceptsBoundaryWildcards()
+ {
+ assertParsed("foo%", LikePattern.Kind.PREFIX, "foo");
+ assertParsed("%foo", LikePattern.Kind.SUFFIX, "foo");
+ assertParsed("%foo%", LikePattern.Kind.CONTAINS, "foo");
+ assertParsed("foo", LikePattern.Kind.MATCHES, "foo");
+ }
+
+ private static void assertRejected(String pattern)
+ {
+ try
+ {
+ LikePattern.parse(ByteBufferUtil.bytes(pattern));
+ Assert.fail("Expecting InvalidRequestException for pattern: " +
pattern);
+ }
+ catch (InvalidRequestException e)
+ {
+ assertTrue("Unexpected message: " + e.getMessage(),
e.getMessage().contains("can't contain a %"));
+ }
+ }
+
+ private static void assertParsed(String pattern, LikePattern.Kind
expectedKind, String expectedValue)
+ {
+ LikePattern parsed = LikePattern.parse(ByteBufferUtil.bytes(pattern));
+ assertEquals(expectedKind, parsed.kind());
+ assertEquals(ByteBufferUtil.bytes(expectedValue), parsed.value());
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]