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]

Reply via email to