You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cassandra.apache.org by ma...@apache.org on 2025/01/14 18:17:43 UTC

(cassandra) branch trunk updated: AST library for CQL-based fuzz tests

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

maedhroz pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 3c1e10b74e AST library for CQL-based fuzz tests
3c1e10b74e is described below

commit 3c1e10b74e64b4fd9ca8bb203d503120c56f0e77
Author: Caleb Rackliffe <ca...@gmail.com>
AuthorDate: Mon Jan 13 18:05:10 2025 -0600

    AST library for CQL-based fuzz tests
    
    patch by Caleb Rackliffe; reviewed by David Capwell for CASSANDRA-20198
    
    Co-authored-by: Caleb Rackliffe <ca...@gmail.com>
    Co-authored-by: David Capwell <dc...@apache.org>
---
 CHANGES.txt                                        |   1 +
 .../cassandra/cql3/ast/AssignmentOperator.java     | 122 +++
 test/unit/org/apache/cassandra/cql3/ast/Bind.java  |  71 ++
 .../apache/cassandra/cql3/ast/CQLFormatter.java    | 128 ++++
 .../cql3/ast/CQLFormatterPrettyPrintTest.java      | 172 +++++
 .../apache/cassandra/cql3/ast/CasCondition.java    |  66 ++
 test/unit/org/apache/cassandra/cql3/ast/Cast.java  |  67 ++
 .../cassandra/cql3/ast/CollectionAccess.java       |  58 ++
 .../org/apache/cassandra/cql3/ast/Conditional.java | 437 +++++++++++
 .../apache/cassandra/cql3/ast/CreateIndexDDL.java  | 271 +++++++
 test/unit/org/apache/cassandra/cql3/ast/DDL.java   |  23 +
 .../org/apache/cassandra/cql3/ast/Element.java     |  63 ++
 .../org/apache/cassandra/cql3/ast/Elements.java    |  32 +
 .../org/apache/cassandra/cql3/ast/Expression.java  |  35 +
 .../cassandra/cql3/ast/ExpressionEvaluator.java    | 146 ++++
 .../apache/cassandra/cql3/ast/ExpressionTest.java  | 125 ++++
 .../apache/cassandra/cql3/ast/FunctionCall.java    | 133 ++++
 .../org/apache/cassandra/cql3/ast/Literal.java     |  71 ++
 .../org/apache/cassandra/cql3/ast/Mutation.java    | 817 +++++++++++++++++++++
 .../org/apache/cassandra/cql3/ast/Operator.java    | 106 +++
 .../org/apache/cassandra/cql3/ast/Reference.java   | 182 +++++
 .../cassandra/cql3/ast/ReferenceExpression.java    |  31 +
 .../unit/org/apache/cassandra/cql3/ast/Select.java | 417 +++++++++++
 .../cassandra/cql3/ast/StandardVisitors.java       |  50 ++
 .../org/apache/cassandra/cql3/ast/Statement.java   | 110 +++
 .../unit/org/apache/cassandra/cql3/ast/Symbol.java | 182 +++++
 .../apache/cassandra/cql3/ast/TableReference.java  |  57 ++
 test/unit/org/apache/cassandra/cql3/ast/Txn.java   | 399 ++++++++++
 .../org/apache/cassandra/cql3/ast/TypeHint.java    |  84 +++
 test/unit/org/apache/cassandra/cql3/ast/Value.java |  36 +
 .../org/apache/cassandra/cql3/ast/Visitor.java     | 159 ++++
 31 files changed, 4651 insertions(+)

diff --git a/CHANGES.txt b/CHANGES.txt
index dddb461b53..f4f3d72b6f 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
 5.1
+ * AST library for CQL-based fuzz tests (CASSANDRA-20198)
  * Support audit logging for JMX operations (CASSANDRA-20128)
  * Enable sorting of nodetool status output (CASSANDRA-20104)
  * Support downgrading after CMS is initialized (CASSANDRA-20145)
diff --git a/test/unit/org/apache/cassandra/cql3/ast/AssignmentOperator.java b/test/unit/org/apache/cassandra/cql3/ast/AssignmentOperator.java
new file mode 100644
index 0000000000..f72fceb817
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/AssignmentOperator.java
@@ -0,0 +1,122 @@
+/*
+ * 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.ast;
+
+import java.util.EnumSet;
+import java.util.stream.Stream;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.CollectionType;
+import org.apache.cassandra.db.marshal.ListType;
+import org.apache.cassandra.db.marshal.MapType;
+import org.apache.cassandra.db.marshal.SetType;
+
+//TODO this is hacky as Mutation takes Expression then figures out where to put it...
+// this can only be applied to UPDATE ... SET
+public class AssignmentOperator implements Expression
+{
+    public enum Kind
+    {
+        ADD('+'),
+        SUBTRACT('-');
+
+        private final char value;
+
+        Kind(char value)
+        {
+            this.value = value;
+        }
+    }
+
+    public final Kind kind;
+    public final Expression right;
+
+    public AssignmentOperator(Kind kind, Expression right)
+    {
+        this.kind = kind;
+        this.right = right;
+    }
+
+    public static EnumSet<Kind> supportsOperators(AbstractType<?> type)
+    {
+        type = type.unwrap();
+        EnumSet<Kind> result = EnumSet.noneOf(Kind.class);
+        for (Operator.Kind supported : Operator.supportsOperators(type))
+        {
+            Kind kind = toKind(supported);
+            if (kind != null)
+                result.add(kind);
+        }
+        if (result.isEmpty())
+        {
+            if (type instanceof CollectionType && type.isMultiCell())
+            {
+                if (type instanceof SetType || type instanceof ListType)
+                    return EnumSet.of(Kind.ADD, Kind.SUBTRACT);
+                if (type instanceof MapType)
+                {
+                    // map supports subtract, but not map - map; only map - set!
+                    // since this is annoying to support, for now dropping -
+                    return EnumSet.of(Kind.ADD);
+                }
+                throw new AssertionError("Unexpected collection type: " + type);
+            }
+        }
+        return result;
+    }
+
+    private static Kind toKind(Operator.Kind kind)
+    {
+        switch (kind)
+        {
+            case ADD: return Kind.ADD;
+            case SUBTRACT: return Kind.SUBTRACT;
+            default: return null;
+        }
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        sb.append(' ').append(kind.value).append("= ");
+        right.toCQL(sb, formatter);
+    }
+
+    @Override
+    public Stream<? extends Element> stream()
+    {
+        return Stream.of(right);
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return right.type();
+    }
+
+    @Override
+    public Expression visit(Visitor v)
+    {
+        var u = v.visit(this);
+        if (u != this) return u;
+        var r = right.visit(v);
+        if (r == right) return this;
+        return new AssignmentOperator(kind, r);
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Bind.java b/test/unit/org/apache/cassandra/cql3/ast/Bind.java
new file mode 100644
index 0000000000..ac58dfc7df
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Bind.java
@@ -0,0 +1,71 @@
+/*
+ * 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.ast;
+
+import java.nio.ByteBuffer;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.Int32Type;
+
+public class Bind implements Value
+{
+    private final Object value;
+    private final AbstractType<?> type;
+
+    public Bind(Object value, AbstractType<?> type)
+    {
+        this.value = value;
+        this.type = type;
+    }
+
+    public static Bind of(int value)
+    {
+        return new Bind(value, Int32Type.instance);
+    }
+
+    @Override
+    public Object value()
+    {
+        return value;
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return type;
+    }
+
+    @Override
+    public ByteBuffer valueEncoded()
+    {
+        return value instanceof ByteBuffer ? (ByteBuffer) value : ((AbstractType) type).decompose(value);
+    }
+
+    @Override
+    public Bind with(Object value, AbstractType<?> type)
+    {
+        return new Bind(value, type);
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        sb.append('?');
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/CQLFormatter.java b/test/unit/org/apache/cassandra/cql3/ast/CQLFormatter.java
new file mode 100644
index 0000000000..a163e7ceec
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/CQLFormatter.java
@@ -0,0 +1,128 @@
+/*
+ * 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.ast;
+
+import static org.apache.cassandra.cql3.ast.Elements.newLine;
+
+public interface CQLFormatter
+{
+    void group(StringBuilder sb);
+    void endgroup(StringBuilder sb);
+
+    void subgroup(StringBuilder sb);
+    void endsubgroup(StringBuilder sb);
+
+    void section(StringBuilder sb);
+    void element(StringBuilder sb);
+
+    class PrettyPrint implements CQLFormatter
+    {
+        private static final int SPACE_PER_GROUP = 2;
+        private int indent;
+        private int subgroup = -1;
+
+        @Override
+        public void group(StringBuilder sb)
+        {
+            indent += SPACE_PER_GROUP;
+        }
+
+        @Override
+        public void endgroup(StringBuilder sb)
+        {
+            if (indent == 0)
+                throw new IllegalStateException("Unable to end group; more endgroup calls than group calls");
+            indent -= SPACE_PER_GROUP;
+        }
+
+        @Override
+        public void subgroup(StringBuilder sb)
+        {
+            if (subgroup != -1)
+                throw new IllegalStateException("Can not have nested subgroups");
+            int offset = sb.lastIndexOf("\n");
+            if (offset == -1)
+                throw new IllegalStateException("subgroup called without a previous section");
+            // offset is before the current indent, so this group will already account for indent
+            subgroup = indent;
+            indent = sb.length() - offset - 1;
+        }
+
+        @Override
+        public void endsubgroup(StringBuilder sb)
+        {
+            // this is for Txn.LET
+            indent = subgroup;
+            subgroup = -1;
+        }
+
+        @Override
+        public void section(StringBuilder sb)
+        {
+            newLine(sb, indent);
+        }
+
+        @Override
+        public void element(StringBuilder sb)
+        {
+            newLine(sb, indent);
+        }
+    }
+
+    enum None implements CQLFormatter
+    {
+        instance;
+
+        @Override
+        public void group(StringBuilder sb)
+        {
+
+        }
+
+        @Override
+        public void endgroup(StringBuilder sb)
+        {
+
+        }
+
+        @Override
+        public void subgroup(StringBuilder sb)
+        {
+
+        }
+
+        @Override
+        public void endsubgroup(StringBuilder sb)
+        {
+
+        }
+
+        @Override
+        public void section(StringBuilder sb)
+        {
+            sb.append(' ');
+        }
+
+        @Override
+        public void element(StringBuilder sb)
+        {
+            sb.append(' ');
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/CQLFormatterPrettyPrintTest.java b/test/unit/org/apache/cassandra/cql3/ast/CQLFormatterPrettyPrintTest.java
new file mode 100644
index 0000000000..f14336d779
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/CQLFormatterPrettyPrintTest.java
@@ -0,0 +1,172 @@
+/*
+ * 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.ast;
+
+import org.junit.Test;
+
+import org.apache.cassandra.db.marshal.Int32Type;
+import org.apache.cassandra.schema.TableMetadata;
+import org.assertj.core.api.Assertions;
+
+public class CQLFormatterPrettyPrintTest
+{
+    private static final TableMetadata TBL1 = TableMetadata.builder("ks", "tbl")
+                                                           .addPartitionKeyColumn("pk", Int32Type.instance)
+                                                           .addClusteringColumn("ck", Int32Type.instance)
+                                                           .addRegularColumn("v0", Int32Type.instance)
+                                                           .addRegularColumn("v1", Int32Type.instance)
+                                                           .build();
+    private static final Symbol pk = new Symbol("pk", Int32Type.instance);
+    private static final Symbol ck = new Symbol("ck", Int32Type.instance);
+    private static final Symbol v0 = new Symbol("v0", Int32Type.instance);
+    private static final Symbol v1 = new Symbol("v1", Int32Type.instance);
+
+    @Test
+    public void selectTest()
+    {
+        Select select = select();
+        Assertions.assertThat(select.toCQL(format())).isEqualTo("SELECT *\n" +
+                                                                "FROM ks.tbl\n" +
+                                                                "WHERE foo = ?\n" +
+                                                                "LIMIT ?");
+    }
+
+    @Test
+    public void insertTest()
+    {
+        Mutation.InsertBuilder builder = Mutation.insert(TBL1);
+        builder.value("pk", 0);
+        builder.value("v1", 0);
+        builder.ttl(Literal.of(42));
+        Assertions.assertThat(builder.build().toCQL(format())).isEqualTo("INSERT INTO ks.tbl (pk, v1)\n" +
+                                                                         "VALUES (?, ?)\n" +
+                                                                         "USING TTL 42");
+    }
+
+    @Test
+    public void updatTest()
+    {
+        Mutation.UpdateBuilder builder = Mutation.update(TBL1);
+        builder.value("pk", 0);
+        builder.set("v0", 0);
+        builder.set("v1", 0);
+        builder.ttl(Literal.of(42));
+        Assertions.assertThat(builder.build().toCQL(format())).isEqualTo("UPDATE ks.tbl\n" +
+                                                                         "USING TTL 42\n" +
+                                                                         "SET\n" +
+                                                                         "  v0=?,\n" +
+                                                                         "  v1=?\n" +
+                                                                         "WHERE \n" +
+                                                                         "  pk = ?");
+
+    }
+
+    @Test
+    public void updateWithInClause()
+    {
+        Mutation.UpdateBuilder builder = Mutation.update(TBL1);
+        builder.set("v0", 0);
+        builder.in("pk", Bind.of(42), Literal.of(78));
+        Assertions.assertThat(builder.build().toCQL(format())).isEqualTo("UPDATE ks.tbl\n" +
+                                                                         "SET\n" +
+                                                                         "  v0=?\n" +
+                                                                         "WHERE \n" +
+                                                                         "  pk IN (?, 78)");
+    }
+
+    @Test
+    public void updateWithBetweenClause()
+    {
+        Mutation.UpdateBuilder builder = Mutation.update(TBL1);
+        builder.set("v0", 0);
+        builder.value("pk", 0);
+        builder.between("ck", Bind.of(42), Literal.of(78));
+        Assertions.assertThat(builder.build().toCQL(format())).isEqualTo("UPDATE ks.tbl\n" +
+                                                                         "SET\n" +
+                                                                         "  v0=?\n" +
+                                                                         "WHERE \n" +
+                                                                         "  pk = ? AND \n" +
+                                                                         "  ck BETWEEN ? AND 78");
+    }
+
+    @Test
+    public void updateWithIsClause()
+    {
+        Mutation.UpdateBuilder builder = Mutation.update(TBL1);
+        builder.set("v0", 0);
+        builder.value("pk", 0);
+        builder.is(new Symbol("ck", Int32Type.instance),
+                             Conditional.Is.Kind.Null);
+        Assertions.assertThat(builder.build().toCQL(format())).isEqualTo("UPDATE ks.tbl\n" +
+                                                                         "SET\n" +
+                                                                         "  v0=?\n" +
+                                                                         "WHERE \n" +
+                                                                         "  pk = ? AND \n" +
+                                                                         "  ck IS NULL");
+    }
+
+    @Test
+    public void deleteTest()
+    {
+        Mutation.DeleteBuilder builder = Mutation.delete(TBL1);
+        builder.column(v1, v0);
+        builder.value("pk", 0);
+        Assertions.assertThat(builder.build().toCQL(format())).isEqualTo("DELETE v1, v0\n" +
+                                                                         "FROM ks.tbl\n" +
+                                                                         "WHERE \n" +
+                                                                         "  pk = ?");
+    }
+
+    @Test
+    public void txnTest()
+    {
+        Txn.Builder builder = Txn.builder();
+        builder.addLet("a", select());
+        builder.addLet("b", select());
+        builder.addReturn(select());
+        Assertions.assertThat(builder.build().toCQL(format())).isEqualTo("BEGIN TRANSACTION\n" +
+                                                                         "  LET a = (SELECT *\n" +
+                                                                         "           FROM ks.tbl\n" +
+                                                                         "           WHERE foo = ?\n" +
+                                                                         "           LIMIT ?);\n" +
+                                                                         "  LET b = (SELECT *\n" +
+                                                                         "           FROM ks.tbl\n" +
+                                                                         "           WHERE foo = ?\n" +
+                                                                         "           LIMIT ?);\n" +
+                                                                         "  SELECT *\n" +
+                                                                         "  FROM ks.tbl\n" +
+                                                                         "  WHERE foo = ?\n" +
+                                                                         "  LIMIT ?;\n" +
+                                                                         "COMMIT TRANSACTION");
+    }
+
+    private static CQLFormatter.PrettyPrint format()
+    {
+        return new CQLFormatter.PrettyPrint();
+    }
+
+    private static Select select()
+    {
+        return Select.builder()
+                     .table("ks", "tbl")
+                     .value("foo",42)
+                     .limit(1)
+                     .build();
+    }
+}
\ No newline at end of file
diff --git a/test/unit/org/apache/cassandra/cql3/ast/CasCondition.java b/test/unit/org/apache/cassandra/cql3/ast/CasCondition.java
new file mode 100644
index 0000000000..9f8d5867e0
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/CasCondition.java
@@ -0,0 +1,66 @@
+/*
+ * 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.ast;
+
+import java.util.stream.Stream;
+
+public interface CasCondition extends Element
+{
+    enum Simple implements CasCondition
+    {
+        NotExists("IF NOT EXISTS"),
+        Exists("IF EXISTS");
+
+        private final String cql;
+
+        Simple(String s)
+        {
+            this.cql = s;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            sb.append(cql);
+        }
+    }
+
+    class IfCondition implements CasCondition
+    {
+        private final Conditional conditional;
+
+        public IfCondition(Conditional conditional)
+        {
+            this.conditional = conditional;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            sb.append("IF ");
+            conditional.toCQL(sb, formatter);
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return Stream.of(conditional);
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Cast.java b/test/unit/org/apache/cassandra/cql3/ast/Cast.java
new file mode 100644
index 0000000000..433ffd13c2
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Cast.java
@@ -0,0 +1,67 @@
+/*
+ * 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.ast;
+
+import java.util.stream.Stream;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+
+public class Cast implements Expression
+{
+    private final Expression e;
+    private final AbstractType<?> type;
+
+    public Cast(Expression e, AbstractType<?> type)
+    {
+        this.e = e;
+        this.type = type;
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        sb.append("CAST (");
+        e.toCQL(sb, formatter);
+        sb.append(" AS ");
+        sb.append(type.asCQL3Type());
+        sb.append(")");
+    }
+
+    @Override
+    public Stream<? extends Element> stream()
+    {
+        return Stream.of(e);
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return type;
+    }
+
+    @Override
+    public Expression visit(Visitor v)
+    {
+        var u = v.visit(this);
+        if (u != this) return u;
+        Expression e = this.e.visit(v);
+        if (e == this.e) return this;
+        return new Cast(e, type);
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/CollectionAccess.java b/test/unit/org/apache/cassandra/cql3/ast/CollectionAccess.java
new file mode 100644
index 0000000000..19c8176f6e
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/CollectionAccess.java
@@ -0,0 +1,58 @@
+/*
+ * 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.ast;
+
+import java.util.stream.Stream;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+
+public class CollectionAccess implements ReferenceExpression
+{
+    private final ReferenceExpression column;
+    private final ReferenceExpression element;
+    private final AbstractType<?> type;
+
+    public CollectionAccess(ReferenceExpression column, ReferenceExpression element, AbstractType<?> type)
+    {
+        this.column = column;
+        this.element = element;
+        this.type = type;
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        column.toCQL(sb, formatter);
+        sb.append('[');
+        element.toCQL(sb, formatter);
+        sb.append(']');
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return type;
+    }
+
+    @Override
+    public Stream<? extends Element> stream()
+    {
+        return Stream.of(column, element);
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Conditional.java b/test/unit/org/apache/cassandra/cql3/ast/Conditional.java
new file mode 100644
index 0000000000..61241adc51
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Conditional.java
@@ -0,0 +1,437 @@
+/*
+ * 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.ast;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import accord.utils.Invariants;
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.BooleanType;
+import org.apache.cassandra.db.marshal.BytesType;
+import org.apache.cassandra.db.marshal.Int32Type;
+
+public interface Conditional extends Expression
+{
+    @Override
+    default AbstractType<?> type()
+    {
+        return BooleanType.instance;
+    }
+
+    @Override
+    default Conditional visit(Visitor v)
+    {
+        return v.visit(this);
+    }
+
+    default List<Conditional> simplify()
+    {
+        return Collections.singletonList(this);
+    }
+
+    class Where implements Conditional
+    {
+        public enum Inequality
+        {
+            EQUAL("="),
+            NOT_EQUAL("!="),
+            GREATER_THAN(">"),
+            GREATER_THAN_EQ(">="),
+            LESS_THAN("<"),
+            LESS_THAN_EQ("<=");
+
+            public final String value;
+
+            Inequality(String value)
+            {
+                this.value = value;
+            }
+        }
+
+        public final Inequality kind;
+        public final Expression lhs;
+        public final Expression rhs;
+
+        private Where(Inequality kind, Expression lhs, Expression rhs)
+        {
+            this.kind = kind;
+            this.lhs = lhs;
+            this.rhs = rhs;
+        }
+
+        public static Where create(Inequality kind, Expression ref, Expression expression)
+        {
+            return new Where(kind, ref, expression);
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            lhs.toCQL(sb, formatter);
+            sb.append(' ').append(kind.value).append(' ');
+            rhs.toCQL(sb, formatter);
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return Stream.of(lhs, rhs);
+        }
+
+        @Override
+        public Conditional visit(Visitor v)
+        {
+            var u = v.visit(this);
+            if (u != this) return u;
+            var lhs = this.lhs.visit(v);
+            var rhs = this.rhs.visit(v);
+            if (lhs == this.lhs && rhs == this.rhs) return this;
+            return new Where(kind, lhs, rhs);
+        }
+    }
+
+    class Between implements Conditional
+    {
+        public final Expression ref, start, end;
+
+        public Between(Expression ref, Expression start, Expression end)
+        {
+            this.ref = ref;
+            this.start = start;
+            this.end = end;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            ref.toCQL(sb, formatter);
+            sb.append(" BETWEEN ");
+            start.toCQL(sb, formatter);
+            sb.append(" AND ");
+            end.toCQL(sb, formatter);
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return Stream.of(ref, start, end);
+        }
+
+        @Override
+        public Conditional visit(Visitor v)
+        {
+            var u = v.visit(this);
+            if (u != this) return u;
+            Expression ref = this.ref.visit(v);
+            Expression start = this.start.visit(v);
+            Expression end = this.end.visit(v);
+            if (ref == this.ref && start == this.start && end == this.end) return this;
+            return new Between(ref, start, end);
+        }
+    }
+
+    class In implements Conditional
+    {
+        public final ReferenceExpression ref;
+        public final List<? extends Expression> expressions;
+
+        public In(ReferenceExpression ref, List<? extends Expression> expressions)
+        {
+            Invariants.checkArgument(!expressions.isEmpty());
+            this.ref = ref;
+            this.expressions = expressions;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            ref.toCQL(sb, formatter);
+            sb.append(" IN ");
+            sb.append('(');
+            for (Expression e : expressions)
+            {
+                e.toCQL(sb, formatter);
+                sb.append(", ");
+            }
+            sb.setLength(sb.length() - 2); // ", "
+            sb.append(')');
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            List<Element> es = new ArrayList<>(expressions.size() + 1);
+            es.add(ref);
+            es.addAll(expressions);
+            return es.stream();
+        }
+
+        @Override
+        public Conditional visit(Visitor v)
+        {
+            var u = v.visit(this);
+            if (u != this) return u;
+            var symbol = this.ref.visit(v);
+            boolean updated = symbol == this.ref;
+            List<Expression> expressions = new ArrayList<>(this.expressions.size());
+            for (Expression e : this.expressions)
+            {
+                var e2 = e.visit(v);
+                updated |= e == e2;
+                expressions.add(e2);
+            }
+            if (!updated) return this;
+            return new In(symbol, expressions);
+        }
+    }
+
+    class Is implements Conditional
+    {
+        public enum Kind
+        {
+            Null("NULL"),
+            NotNull("NOT NULL");
+
+            private final String cql;
+
+            Kind(String s)
+            {
+                this.cql = s;
+            }
+        }
+
+        public final ReferenceExpression reference;
+        public final Kind kind;
+
+        public Is(String symbol, Kind kind)
+        {
+            this(Symbol.unknownType(symbol), kind);
+        }
+
+        public Is(ReferenceExpression reference, Kind kind)
+        {
+            this.reference = reference;
+            this.kind = kind;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            reference.toCQL(sb, formatter);
+            sb.append(" IS ").append(kind.cql);
+        }
+    }
+
+    class And implements Conditional
+    {
+        public final Conditional left, right;
+
+        public And(Conditional left, Conditional right)
+        {
+            this.left = left;
+            this.right = right;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            left.toCQL(sb, formatter);
+            sb.append(" AND ");
+            right.toCQL(sb, formatter);
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return Stream.of(left, right);
+        }
+
+        @Override
+        public Conditional visit(Visitor v)
+        {
+            var u = v.visit(this);
+            if (u != this) return u;
+            Conditional left = this.left.visit(v);
+            Conditional right = this.right.visit(v);
+            if (this.left == left && this.right == right) return this;
+            return new And(left, right);
+        }
+
+        @Override
+        public List<Conditional> simplify()
+        {
+            List<Conditional> result = new ArrayList<>();
+            result.addAll(left.simplify());
+            result.addAll(right.simplify());
+            return result;
+        }
+    }
+
+    interface EqBuilder<T extends EqBuilder<T>>
+    {
+        T value(Symbol symbol, Expression e);
+        default T value(Symbol symbol, Object value)
+        {
+            return value(symbol, new Bind(value, symbol.type()));
+        }
+
+        default T value(String symbol, int e)
+        {
+            return value(new Symbol(symbol, Int32Type.instance), Bind.of(e));
+        }
+    }
+
+    interface ConditionalBuilder<T extends ConditionalBuilder<T>> extends EqBuilder<T>
+    {
+
+        T where(Expression ref, Where.Inequality kind, Expression expression);
+        default T where(Expression ref, Where.Inequality kind, Object value)
+        {
+            return where(ref, kind, new Bind(value, ref.type()));
+        }
+
+        default <Type> T where(String name, Where.Inequality kind, Type value, AbstractType<Type> type)
+        {
+            return where(new Symbol(name, type), kind, new Bind(value, type));
+        }
+
+        default T where(String name, Where.Inequality kind, int value)
+        {
+            return where(name, kind, value, Int32Type.instance);
+        }
+
+        T between(Expression ref, Expression start, Expression end);
+
+        default T between(String name, Expression start, Expression end)
+        {
+            return between(new Symbol(name, start.type()), start, end);
+        }
+
+        T in(ReferenceExpression ref, List<? extends Expression> expressions);
+
+        default T in(ReferenceExpression ref, Expression... expressions)
+        {
+            return in(ref, Arrays.asList(expressions));
+        }
+
+        default T in(String name, Expression... expressions)
+        {
+            if (expressions == null || expressions.length == 0)
+                throw new IllegalArgumentException("expressions may not be empty");
+            return in(new Symbol(name, expressions[0].type()), expressions);
+        }
+
+        default T in(String name, int... values)
+        {
+            return in(name, Int32Type.instance, IntStream.of(values).boxed().collect(Collectors.toList()));
+        }
+
+        default <Type> T in(String name, AbstractType<Type> type, Type... values)
+        {
+            return in(name, type, Arrays.asList(values));
+        }
+
+        default <Type> T in(String name, AbstractType<Type> type, List<Type> values)
+        {
+            return in(new Symbol(name, type), values.stream().map(v -> new Bind(v, type)).collect(Collectors.toList()));
+        }
+
+        T is(Symbol ref, Is.Kind kind);
+
+        @Override
+        default T value(Symbol symbol, Expression e)
+        {
+            return where(symbol, Where.Inequality.EQUAL, e);
+        }
+
+        default T value(String symbol, int e)
+        {
+            return value(symbol, e, Int32Type.instance);
+        }
+
+        default T value(String symbol, ByteBuffer e)
+        {
+            return value(symbol, e, BytesType.instance);
+        }
+
+        default <Type> T value(String symbol, Type value, AbstractType<Type> type)
+        {
+            return value(new Symbol(symbol, type), new Bind(value, type));
+        }
+    }
+
+    class Builder implements ConditionalBuilder<Builder>
+    {
+        private final List<Conditional> sub = new ArrayList<>();
+
+        public boolean isEmpty()
+        {
+            return sub.isEmpty();
+        }
+
+        private Builder add(Conditional conditional)
+        {
+            sub.add(conditional);
+            return this;
+        }
+
+        @Override
+        public Builder where(Expression ref, Where.Inequality kind, Expression expression)
+        {
+            return add(Where.create(kind, ref, expression));
+        }
+
+        @Override
+        public Builder between(Expression ref, Expression start, Expression end)
+        {
+            return add(new Between(ref, start, end));
+        }
+
+        @Override
+        public Builder in(ReferenceExpression symbol, List<? extends Expression> expressions)
+        {
+            return add(new In(symbol, expressions));
+        }
+
+        @Override
+        public Builder is(Symbol ref, Is.Kind kind)
+        {
+            return add(new Is(ref, kind));
+        }
+
+        public Conditional build()
+        {
+            if (sub.isEmpty())
+                throw new IllegalStateException("Unable to build an empty Conditional");
+            if (sub.size() == 1)
+                return sub.get(0);
+            Conditional accum = sub.get(0);
+            for (int i = 1; i < sub.size(); i++)
+                accum = new And(accum, sub.get(i));
+            return accum;
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/CreateIndexDDL.java b/test/unit/org/apache/cassandra/cql3/ast/CreateIndexDDL.java
new file mode 100644
index 0000000000..252dd99bc9
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/CreateIndexDDL.java
@@ -0,0 +1,271 @@
+/*
+ * 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.ast;
+
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import com.google.common.collect.ImmutableSet;
+
+import org.apache.cassandra.cql3.ast.Symbol.UnquotedSymbol;
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.AsciiType;
+import org.apache.cassandra.db.marshal.BooleanType;
+import org.apache.cassandra.db.marshal.CompositeType;
+import org.apache.cassandra.db.marshal.UTF8Type;
+import org.apache.cassandra.db.marshal.UUIDType;
+import org.apache.cassandra.index.sai.StorageAttachedIndex;
+import org.apache.cassandra.schema.ColumnMetadata;
+import org.apache.cassandra.schema.TableMetadata;
+
+//TODO (now): replace with IndexMetadata?  Rather than create a custom DDL type can just leverage the existing metadata like Table/Keyspace
+public class CreateIndexDDL implements Element
+{
+    public enum Version { V1, V2 }
+    public enum QueryType { Eq, Range }
+
+    public interface Indexer
+    {
+        enum Kind { legacy, sai }
+        Kind kind();
+        default boolean isCustom()
+        {
+            return kind() != Kind.legacy;
+        }
+        UnquotedSymbol name();
+        default UnquotedSymbol longName()
+        {
+            return name();
+        }
+        boolean supported(TableMetadata table, ColumnMetadata column);
+        default EnumSet<QueryType> supportedQueries(AbstractType<?> type)
+        {
+            return EnumSet.of(QueryType.Eq);
+        }
+    }
+
+    public static List<Indexer> supportedIndexers()
+    {
+        return Arrays.asList(LEGACY, SAI);
+    }
+
+    private static boolean standardSupported(TableMetadata metadata, ColumnMetadata col)
+    {
+        if (metadata.partitionKeyColumns().size() == 1 && col.isPartitionKey()) return false;
+        AbstractType<?> type = col.type.unwrap();
+        if (type.isUDT() && type.isMultiCell()) return false; // non-frozen UDTs are not supported
+        if (type.referencesDuration()) return false; // Duration is not allowed!  See org.apache.cassandra.cql3.statements.schema.CreateIndexStatement.validateIndexTarget
+        return true;
+    }
+
+    private static boolean isFrozen(AbstractType<?> type)
+    {
+        return !type.subTypes().isEmpty() && !type.isMultiCell();
+    }
+
+    public static final Indexer LEGACY = new Indexer()
+    {
+
+        @Override
+        public Kind kind()
+        {
+            return Kind.legacy;
+        }
+
+        @Override
+        public UnquotedSymbol name()
+        {
+            return new UnquotedSymbol("legacy_local_table", UTF8Type.instance);
+        }
+
+        @Override
+        public boolean supported(TableMetadata table, ColumnMetadata column)
+        {
+            return standardSupported(table, column);
+        }
+    };
+
+    private static final Set<AbstractType<?>> SAI_EQ_ONLY = ImmutableSet.of(UTF8Type.instance, AsciiType.instance,
+                                                                            BooleanType.instance,
+                                                                            UUIDType.instance);
+
+    public static final Indexer SAI = new Indexer()
+    {
+        @Override
+        public Kind kind()
+        {
+            return Kind.sai;
+        }
+
+        @Override
+        public UnquotedSymbol name()
+        {
+            return new UnquotedSymbol("SAI", UTF8Type.instance);
+        }
+
+        @Override
+        public UnquotedSymbol longName()
+        {
+            return new UnquotedSymbol("StorageAttachedIndex", UTF8Type.instance);
+        }
+
+        @Override
+        public boolean supported(TableMetadata table, ColumnMetadata col)
+        {
+            if (!standardSupported(table, col)) return false;
+            AbstractType<?> type = col.type.unwrap();
+            if (type instanceof CompositeType)
+            {
+                // each element must be SUPPORTED_TYPES only...
+                if (type.subTypes().stream().allMatch(StorageAttachedIndex.SUPPORTED_TYPES::contains))
+                    return true;
+            }
+            else if (((isFrozen(type) && !type.isVector()) || StorageAttachedIndex.SUPPORTED_TYPES.contains(type.asCQL3Type())))
+                return true;
+            return false;
+        }
+
+        @Override
+        public EnumSet<QueryType> supportedQueries(AbstractType<?> type)
+        {
+            type = type.unwrap();
+            if (SAI_EQ_ONLY.contains(type))
+                return EnumSet.of(QueryType.Eq);
+            return EnumSet.allOf(QueryType.class);
+        }
+    };
+
+    public final Version version;
+    public final Indexer indexer;
+    public final Optional<Symbol> name;
+    public final TableReference on;
+    public final List<ReferenceExpression> references;
+    public final Map<String, String> options;
+
+    public CreateIndexDDL(Version version, Indexer indexer, Optional<Symbol> name, TableReference on, List<ReferenceExpression> references, Map<String, String> options)
+    {
+        this.version = version;
+        this.indexer = indexer;
+        this.name = name;
+        this.on = on;
+        this.references = references;
+        this.options = options;
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        switch (version)
+        {
+            case V1:
+                if (indexer.isCustom()) sb.append("CREATE CUSTOM INDEX");
+                else                    sb.append("CREATE INDEX");
+                break;
+            case V2:
+                sb.append("CREATE INDEX");
+                break;
+            default:
+                throw new UnsupportedOperationException(version.name());
+        }
+        if (name.isPresent())
+        {
+            sb.append(' ');
+            name.get().toCQL(sb, formatter);
+        }
+        formatter.section(sb);
+        sb.append("ON ");
+        on.toCQL(sb, formatter);
+        sb.append('(');
+        for (ReferenceExpression ref : references)
+        {
+            ref.toCQL(sb, formatter);
+            sb.append(", ");
+        }
+        sb.setLength(sb.length() - 2); // remove last ", "
+        sb.append(')');
+        UnquotedSymbol indexerName = null;
+        switch (version)
+        {
+            case V1:
+                if (indexer.isCustom())
+                    indexerName = indexer.longName();
+                break;
+            case V2:
+                indexerName = indexer.name();
+                break;
+            default:
+                throw new UnsupportedOperationException(version.name());
+        }
+        if (indexerName != null)
+        {
+            formatter.section(sb);
+            sb.append("USING '");
+            indexerName.toCQL(sb, formatter);
+            sb.append("'");
+        }
+        if (!options.isEmpty())
+        {
+            formatter.section(sb);
+            sb.append("WITH OPTIONS = {");
+            for (Map.Entry<String, String> e : options.entrySet())
+                sb.append("'").append(e.getKey()).append("': '").append(e.getValue()).append("', ");
+            sb.setLength(sb.length() - 2); // remove ", "
+            sb.append('}');
+        }
+    }
+
+    public static class CollectionReference implements ReferenceExpression
+    {
+        public enum Kind { FULL, KEYS, ENTRIES }
+
+        public final Kind kind;
+        public final ReferenceExpression column;
+
+        public CollectionReference(Kind kind, ReferenceExpression column)
+        {
+            this.kind = kind;
+            this.column = column;
+        }
+
+        @Override
+        public AbstractType<?> type()
+        {
+            return column.type();
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            sb.append(kind.name()).append('(');
+            column.toCQL(sb, formatter);
+            sb.append(')');
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return Stream.of(column);
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/DDL.java b/test/unit/org/apache/cassandra/cql3/ast/DDL.java
new file mode 100644
index 0000000000..7575bec7a4
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/DDL.java
@@ -0,0 +1,23 @@
+/*
+ * 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.ast;
+
+public interface DDL extends Statement
+{
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Element.java b/test/unit/org/apache/cassandra/cql3/ast/Element.java
new file mode 100644
index 0000000000..05bcd92ce5
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Element.java
@@ -0,0 +1,63 @@
+/*
+ * 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.ast;
+
+import java.util.stream.Stream;
+
+public interface Element
+{
+    void toCQL(StringBuilder sb, CQLFormatter formatter);
+
+    default void toCQL(StringBuilder sb)
+    {
+        toCQL(sb, CQLFormatter.None.instance);
+    }
+
+    default String toCQL()
+    {
+        StringBuilder sb = new StringBuilder();
+        toCQL(sb, CQLFormatter.None.instance);
+        return sb.toString();
+    }
+
+    default String toCQL(CQLFormatter formatter)
+    {
+        StringBuilder sb = new StringBuilder();
+        toCQL(sb, formatter);
+        return sb.toString();
+    }
+
+    default Stream<? extends Element> stream()
+    {
+        return Stream.empty();
+    }
+
+    default Stream<? extends Element> streamRecursive()
+    {
+        return streamRecursive(false);
+    }
+
+    default Stream<? extends Element> streamRecursive(boolean includeSelf)
+    {
+        Stream<Element> stream = stream().flatMap(e -> Stream.concat(Stream.of(e), e.streamRecursive()));
+        if (includeSelf)
+            stream = Stream.concat(Stream.of(this), stream);
+        return stream;
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Elements.java b/test/unit/org/apache/cassandra/cql3/ast/Elements.java
new file mode 100644
index 0000000000..f750c4efc1
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Elements.java
@@ -0,0 +1,32 @@
+/*
+ * 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.ast;
+
+public final class Elements
+{
+    private Elements()
+    {}
+
+    public static void newLine(StringBuilder sb, int indent)
+    {
+        sb.append('\n');
+        for (int i = 0; i < indent; i++)
+            sb.append(' ');
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Expression.java b/test/unit/org/apache/cassandra/cql3/ast/Expression.java
new file mode 100644
index 0000000000..96bc41a78c
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Expression.java
@@ -0,0 +1,35 @@
+/*
+ * 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.ast;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+
+public interface Expression extends Element
+{
+    AbstractType<?> type();
+    default String name()
+    {
+        return toCQL();
+    }
+
+    default Expression visit(Visitor v)
+    {
+        return v.visit(this);
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/ExpressionEvaluator.java b/test/unit/org/apache/cassandra/cql3/ast/ExpressionEvaluator.java
new file mode 100644
index 0000000000..8270b438f4
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/ExpressionEvaluator.java
@@ -0,0 +1,146 @@
+/*
+ * 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.ast;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.util.Optional;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+
+import static java.util.Optional.of;
+
+public class ExpressionEvaluator
+{
+    public static Optional<Object> tryEval(Expression e)
+    {
+        if (e instanceof Value)
+            return of(((Value) e).value());
+        if (e instanceof TypeHint)
+            return tryEval(((TypeHint) e).e);
+        if (e instanceof Operator)
+            return tryEval((Operator) e);
+        return Optional.empty();
+    }
+
+    public static Optional<Object> tryEval(Operator e)
+    {
+        switch (e.kind)
+        {
+            case ADD:
+            {
+                var lhsOpt = tryEval(e.left);
+                var rhsOpt = tryEval(e.right);
+                if (lhsOpt.isEmpty() || rhsOpt.isEmpty())
+                    return Optional.empty();
+                Object lhs = lhsOpt.get();
+                Object rhs = rhsOpt.get();
+                if (lhs instanceof Byte)
+                    return of((byte) (((Byte) lhs) + ((Byte) rhs)));
+                if (lhs instanceof Short)
+                    return of((short) (((Short) lhs) + ((Short) rhs)));
+                if (lhs instanceof Integer)
+                    return of((int) (((Integer) lhs) + ((Integer) rhs)));
+                if (lhs instanceof Long)
+                    return of((long) (((Long) lhs) + ((Long) rhs)));
+                if (lhs instanceof Float)
+                    return of((float) (((Float) lhs) + ((Float) rhs)));
+                if (lhs instanceof Double)
+                    return of((double) (((Double) lhs) + ((Double) rhs)));
+                if (lhs instanceof BigInteger)
+                    return of(((BigInteger) lhs).add((BigInteger) rhs));
+                if (lhs instanceof BigDecimal)
+                    return of(((BigDecimal) lhs).add((BigDecimal) rhs));
+                if (lhs instanceof String)
+                    return of(lhs.toString() + rhs.toString());
+                throw new UnsupportedOperationException("Unexpected type: " + lhs.getClass());
+            }
+            case SUBTRACT:
+            {
+                var lhsOpt = tryEval(e.left);
+                var rhsOpt = tryEval(e.right);
+                if (lhsOpt.isEmpty() || rhsOpt.isEmpty())
+                    return Optional.empty();
+                Object lhs = lhsOpt.get();
+                Object rhs = rhsOpt.get();
+                if (lhs instanceof Byte)
+                    return of((byte) (((Byte) lhs) - ((Byte) rhs)));
+                if (lhs instanceof Short)
+                    return of((short) (((Short) lhs) - ((Short) rhs)));
+                if (lhs instanceof Integer)
+                    return of((int) (((Integer) lhs) - ((Integer) rhs)));
+                if (lhs instanceof Long)
+                    return of((long) (((Long) lhs) - ((Long) rhs)));
+                if (lhs instanceof Float)
+                    return of((float) (((Float) lhs) - ((Float) rhs)));
+                if (lhs instanceof Double)
+                    return of((double) (((Double) lhs) - ((Double) rhs)));
+                if (lhs instanceof BigInteger)
+                    return of(((BigInteger) lhs).subtract((BigInteger) rhs));
+                if (lhs instanceof BigDecimal)
+                    return of(((BigDecimal) lhs).subtract((BigDecimal) rhs));
+                throw new UnsupportedOperationException("Unexpected type: " + lhs.getClass());
+            }
+            case MULTIPLY:
+            {
+                var lhsOpt = tryEval(e.left);
+                var rhsOpt = tryEval(e.right);
+                if (lhsOpt.isEmpty() || rhsOpt.isEmpty())
+                    return Optional.empty();
+                Object lhs = lhsOpt.get();
+                Object rhs = rhsOpt.get();
+                if (lhs instanceof Byte)
+                    return of((byte) (((Byte) lhs) * ((Byte) rhs)));
+                if (lhs instanceof Short)
+                    return of((short) (((Short) lhs) * ((Short) rhs)));
+                if (lhs instanceof Integer)
+                    return of((int) (((Integer) lhs) * ((Integer) rhs)));
+                if (lhs instanceof Long)
+                    return of((long) (((Long) lhs) * ((Long) rhs)));
+                if (lhs instanceof Float)
+                    return of((float) (((Float) lhs) * ((Float) rhs)));
+                if (lhs instanceof Double)
+                    return of((double) ((Double) lhs) * ((Double) rhs));
+                if (lhs instanceof BigInteger)
+                    return of(((BigInteger) lhs).multiply((BigInteger) rhs));
+                if (lhs instanceof BigDecimal)
+                    return of(((BigDecimal) lhs).multiply((BigDecimal) rhs));
+                throw new UnsupportedOperationException("Unexpected type: " + lhs.getClass());
+            }
+            default:
+                throw new UnsupportedOperationException(e.kind.name());
+        }
+    }
+
+    public static Optional<ByteBuffer> tryEvalEncoded(Expression e)
+    {
+        return tryEval(e).map(v -> {
+            if (v instanceof ByteBuffer) return (ByteBuffer) v;
+            try
+            {
+                return ((AbstractType) e.type()).decompose(v);
+            }
+            catch (Throwable t)
+            {
+                throw t;
+            }
+        });
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/ExpressionTest.java b/test/unit/org/apache/cassandra/cql3/ast/ExpressionTest.java
new file mode 100644
index 0000000000..333f3dec8e
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/ExpressionTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.ast;
+
+import org.junit.Test;
+
+import accord.utils.Gen;
+import accord.utils.Gens;
+import org.apache.cassandra.cql3.ast.Conditional.And;
+import org.apache.cassandra.cql3.ast.Conditional.Where;
+import org.apache.cassandra.db.marshal.Int32Type;
+import org.assertj.core.api.Assertions;
+
+import static accord.utils.Property.qt;
+
+public class ExpressionTest
+{
+    private static final Visitor.CompositeVisitor COMPOSITE_VISITOR = Visitor.CompositeVisitor.of(StandardVisitors.BIND_TO_LITERAL);
+
+    @Test
+    public void simple()
+    {
+        qt().forAll(Gens.random(), expressions()).check((rs, ex) -> {
+            Visitor v = rs.nextBoolean() ? StandardVisitors.BIND_TO_LITERAL : COMPOSITE_VISITOR;
+            Expression update = ex.visit(v);
+            assertNoBind(update);
+        });
+    }
+
+    @Test
+    public void select()
+    {
+        assertNoBind(selectWithBind().visit(StandardVisitors.BIND_TO_LITERAL));
+    }
+
+    @Test
+    public void txn()
+    {
+        Txn txn = Txn.builder()
+                     .addLet("a", selectWithBind())
+                     .addReturn(selectWithBind())
+                     .build();
+        assertNoBind(txn.visit(StandardVisitors.BIND_TO_LITERAL));
+    }
+
+    private static Select selectWithBind()
+    {
+        return Select.builder()
+                     .table("ks", "tbl")
+                     .value("foo", 42)
+                     .limit(1)
+                     .build();
+    }
+
+    private static void assertNoBind(Element update)
+    {
+        Assertions.assertThat(update
+                              .streamRecursive(true)
+                              .filter(e -> e instanceof Bind)
+                              .count()).isEqualTo(0);
+    }
+
+    private static Gen<Conditional> conditionals()
+    {
+        return rs -> {
+            // in / where / and
+            switch (rs.nextInt(0, 2))
+            {
+                case 0:
+                    return new Conditional.In(new Symbol("col", Int32Type.instance), Gens.lists(expressions()).ofSizeBetween(1, 3).next(rs));
+                case 1:
+                    return Where.create(Where.Inequality.EQUAL, new Symbol("col", Int32Type.instance), expressions().next(rs));
+                case 2:
+                {
+                    var gen = conditionals();
+                    return new And(gen.next(rs), gen.next(rs));
+                }
+                default:
+                    throw new UnsupportedOperationException();
+            }
+        };
+    }
+
+    private static Gen<Expression> expressions()
+    {
+        return rs -> {
+            var subExpression = expressions().filter(e -> e.type() == Int32Type.instance);
+            if (rs.decide(.1))
+            {
+                // operator
+                return new Operator(Operator.Kind.ADD, subExpression.next(rs), subExpression.next(rs));
+            }
+            if (rs.decide(.1))
+            {
+                return new AssignmentOperator(AssignmentOperator.Kind.ADD, subExpression.next(rs));
+            }
+            if (rs.decide(.1))
+                return new Cast(subExpression.next(rs), Int32Type.instance);
+            if (rs.decide(.1))
+                return new FunctionCall("fn", Gens.lists(subExpression).ofSizeBetween(1, 10).next(rs), Int32Type.instance);
+            if (rs.decide(.1))
+                return new TypeHint(subExpression.next(rs));
+            if (rs.decide(.1))
+                return conditionals().next(rs);
+            int value = rs.nextInt();
+            return rs.nextBoolean() ? Literal.of(value) : Bind.of(value);
+        };
+    }
+}
\ No newline at end of file
diff --git a/test/unit/org/apache/cassandra/cql3/ast/FunctionCall.java b/test/unit/org/apache/cassandra/cql3/ast/FunctionCall.java
new file mode 100644
index 0000000000..afd8bac2a3
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/FunctionCall.java
@@ -0,0 +1,133 @@
+/*
+ * 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.ast;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.BytesType;
+import org.apache.cassandra.db.marshal.LongType;
+
+public class FunctionCall implements Expression
+{
+    public final String name;
+    public final List<? extends Expression> arguments;
+    public final AbstractType<?> returnType;
+
+    public FunctionCall(String name, List<? extends Expression> arguments, AbstractType<?> returnType)
+    {
+        this.name = name;
+        this.arguments = arguments;
+        this.returnType = returnType;
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        sb.append(name).append('(');
+        for (Expression e : arguments)
+        {
+            e.toCQL(sb, formatter);
+            sb.append(", ");
+        }
+        if (!arguments.isEmpty())
+            sb.setLength(sb.length() - 2);
+        sb.append(')');
+    }
+
+    @Override
+    public Stream<? extends Element> stream()
+    {
+        return arguments.stream();
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return returnType;
+    }
+
+    @Override
+    public String name()
+    {
+        return name;
+    }
+
+    @Override
+    public Expression visit(Visitor v)
+    {
+        var u = v.visit(this);
+        if (u != this) return u;
+        boolean updated = false;
+        List<Expression> as = new ArrayList<>(arguments.size());
+        for (Expression e : arguments)
+        {
+            u = e.visit(v);
+            if (u != e)
+                updated = true;
+            as.add(u);
+        }
+        if (!updated) return this;
+        return new FunctionCall(name, as, returnType);
+    }
+
+    public static FunctionCall writetime(String column, AbstractType<?> type)
+    {
+        return new FunctionCall("writetime", Collections.singletonList(new Symbol(column, type)), LongType.instance);
+    }
+
+    public static FunctionCall countStar()
+    {
+        return new FunctionCall("count(*)", Collections.emptyList(), LongType.instance);
+    }
+
+    public static FunctionCall count(String symbol)
+    {
+        return count(Symbol.unknownType(symbol));
+    }
+
+    public static FunctionCall count(Symbol symbol)
+    {
+        return new FunctionCall("count", Collections.singletonList(symbol), LongType.instance);
+    }
+
+    public static FunctionCall tokenByColumns(Symbol... columns)
+    {
+        return tokenByColumns(Arrays.asList(columns));
+    }
+
+    public static FunctionCall tokenByColumns(List<Symbol> columns)
+    {
+        return new FunctionCall("token", columns, BytesType.instance);
+    }
+
+    public static FunctionCall tokenByValue(Value... values)
+    {
+        return tokenByValue(Arrays.asList(values));
+    }
+
+    public static FunctionCall tokenByValue(List<? extends Value> values)
+    {
+        return new FunctionCall("token", values, BytesType.instance);
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Literal.java b/test/unit/org/apache/cassandra/cql3/ast/Literal.java
new file mode 100644
index 0000000000..ad8e3ddc74
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Literal.java
@@ -0,0 +1,71 @@
+/*
+ * 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.ast;
+
+import java.nio.ByteBuffer;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.Int32Type;
+
+public class Literal implements Value
+{
+    private final Object value;
+    private final AbstractType<?> type;
+
+    public Literal(Object value, AbstractType<?> type)
+    {
+        this.value = value;
+        this.type = type;
+    }
+
+    public static Literal of(int value)
+    {
+        return new Literal(value, Int32Type.instance);
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return type;
+    }
+
+    @Override
+    public Object value()
+    {
+        return value;
+    }
+
+    @Override
+    public ByteBuffer valueEncoded()
+    {
+        return value instanceof ByteBuffer ? (ByteBuffer) value : ((AbstractType) type).decompose(value);
+    }
+
+    @Override
+    public Literal with(Object value, AbstractType<?> type)
+    {
+        return new Literal(value, type);
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        sb.append(type.asCQL3Type().toCQLLiteral(valueEncoded()));
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Mutation.java b/test/unit/org/apache/cassandra/cql3/ast/Mutation.java
new file mode 100644
index 0000000000..729bb8127a
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Mutation.java
@@ -0,0 +1,817 @@
+/*
+ * 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.ast;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+
+import org.apache.cassandra.db.marshal.Int32Type;
+import org.apache.cassandra.schema.ColumnMetadata;
+import org.apache.cassandra.schema.TableMetadata;
+
+public abstract class Mutation implements Statement
+{
+    public enum Kind
+    {INSERT, UPDATE, DELETE}
+
+    public final Kind kind;
+    public final TableReference table;
+
+    protected Mutation(Kind kind, TableReference table)
+    {
+        this.kind = kind;
+        this.table = table;
+    }
+
+    public abstract boolean isCas();
+
+    public final Kind mutationKind()
+    {
+        return kind;
+    }
+
+    @Override
+    public final Statement.Kind kind()
+    {
+        return Statement.Kind.MUTATION;
+    }
+
+    @Override
+    public String toString()
+    {
+        return detailedToString();
+    }
+
+    public static InsertBuilder insert(TableMetadata metadata)
+    {
+        return new InsertBuilder(metadata);
+    }
+
+    public static UpdateBuilder update(TableMetadata metadata)
+    {
+        return new UpdateBuilder(metadata);
+    }
+
+    public static DeleteBuilder delete(TableMetadata metadata)
+    {
+        return new DeleteBuilder(metadata);
+    }
+
+    public static LinkedHashSet<Symbol> toSet(Iterable<ColumnMetadata> columns)
+    {
+        LinkedHashSet<Symbol> set = new LinkedHashSet<>();
+        for (var col : columns)
+            set.add(Symbol.from(col));
+        return set;
+    }
+
+    private static void toCQLWhere(Conditional conditional, StringBuilder sb, CQLFormatter formatter)
+    {
+        formatter.section(sb);
+        sb.append("WHERE ");
+        List<Conditional> where = conditional.simplify();
+        formatter.group(sb);
+        for (var c : where)
+        {
+            formatter.element(sb);
+            c.toCQL(sb, formatter);
+            sb.append(" AND ");
+        }
+        sb.setLength(sb.length() - 5); // 5 = " AND "
+        formatter.endgroup(sb);
+    }
+
+    public static class TTL implements Element
+    {
+        public final Value value;
+
+        public TTL(Value value)
+        {
+            this.value = value;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            sb.append("TTL ");
+            value.toCQL(sb, formatter);
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return Stream.of(value);
+        }
+    }
+
+    public static class Timestamp implements Element
+    {
+        public final Value value;
+
+        public Timestamp(Value value)
+        {
+            this.value = value;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            sb.append("TIMESTAMP ");
+            value.toCQL(sb, formatter);
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return Stream.of(value);
+        }
+    }
+
+    public static class Using implements Element
+    {
+        public final Optional<TTL> ttl;
+        public final Optional<Timestamp> timestamp;
+
+        public Using(Optional<TTL> ttl, Optional<Timestamp> timestamp)
+        {
+            this.ttl = ttl;
+            this.timestamp = timestamp;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            if (ttl.isEmpty() && timestamp.isEmpty())
+                return;
+            sb.append("USING ");
+            if (ttl.isPresent())
+                ttl.get().toCQL(sb, formatter);
+            if (timestamp.isPresent())
+            {
+                if (ttl.isPresent())
+                    sb.append(" AND ");
+                timestamp.get().toCQL(sb, formatter);
+            }
+        }
+    }
+
+    public static class Insert extends Mutation
+    {
+        public final LinkedHashMap<Symbol, Expression> values;
+        public final boolean ifNotExists;
+        public final Optional<Using> using;
+
+        public Insert(TableReference table, LinkedHashMap<Symbol, Expression> values, boolean ifNotExists, Optional<Using> using)
+        {
+            super(Mutation.Kind.INSERT, table);
+            this.values = values;
+            this.ifNotExists = ifNotExists;
+            this.using = using;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            // INSERT INTO [keyspace_name.] table_name (column_list)
+            sb.append("INSERT INTO ");
+            table.toCQL(sb, formatter);
+            sb.append(" (");
+            for (Symbol col : values.keySet())
+            {
+                col.toCQL(sb, formatter);
+                sb.append(", ");
+            }
+            sb.setLength(sb.length() - 2); // 2 = ", "
+            sb.append(")");
+            // VALUES (column_values)
+            formatter.section(sb);
+            sb.append("VALUES (");
+            for (Expression e : values.values())
+            {
+                e.toCQL(sb, formatter);
+                sb.append(", ");
+            }
+            sb.setLength(sb.length() - 2); // 2 = ", "
+            sb.append(")");
+            // [IF NOT EXISTS]
+            if (ifNotExists)
+            {
+                formatter.section(sb);
+                sb.append("IF NOT EXISTS");
+            }
+            // [USING TTL seconds | TIMESTAMP epoch_in_microseconds]
+            if (using.isPresent())
+            {
+                formatter.section(sb);
+                using.get().toCQL(sb, formatter);
+            }
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            List<Element> elements = new ArrayList<>(1 + (using.isPresent() ? 1 : 0) + (values.size() * 2));
+            elements.add(table);
+            elements.addAll(values.keySet());
+            elements.addAll(values.values());
+            if (using.isPresent())
+                elements.add(using.get());
+            return elements.stream();
+        }
+
+        @Override
+        public Statement visit(Visitor v)
+        {
+            var u = v.visit(this);
+            if (u != this) return u;
+            boolean updated = false;
+            LinkedHashMap<Symbol, Expression> copied = new LinkedHashMap<>(values.size());
+            for (var e : values.entrySet())
+            {
+                Symbol s = e.getKey();
+                Symbol s2 = s.visit("INSERT", v);
+                if (s != s2)
+                    updated = true;
+                Expression ex = e.getValue();
+                Expression ex2 = ex.visit(v);
+                if (ex != ex2)
+                    updated = true;
+                copied.put(s2, ex2);
+            }
+            if (!updated) return this;
+            return new Insert(table, copied, ifNotExists, using);
+        }
+
+        @Override
+        public boolean isCas()
+        {
+            return ifNotExists;
+        }
+    }
+
+    public static class Update extends Mutation
+    {
+        public final Optional<Using> using;
+        public final LinkedHashMap<Symbol, Expression> set;
+        public final Conditional where;
+        public final Optional<? extends CasCondition> casCondition;
+
+        public Update(TableReference table, Optional<Using> using, LinkedHashMap<Symbol, Expression> set, Conditional where, Optional<? extends CasCondition> casCondition)
+        {
+            super(Mutation.Kind.UPDATE, table);
+            this.using = using;
+            this.set = set;
+            this.where = where;
+            this.casCondition = casCondition;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            // UPDATE [keyspace_name.] table_name
+            sb.append("UPDATE ");
+            table.toCQL(sb, formatter);
+            // [USING TTL time_value | USING TIMESTAMP timestamp_value]
+            if (using.isPresent())
+            {
+                formatter.section(sb);
+                using.get().toCQL(sb, formatter);
+            }
+            // SET assignment [, assignment] . . .
+            formatter.section(sb);
+            sb.append("SET");
+            formatter.group(sb);
+            for (var e : set.entrySet())
+            {
+                formatter.element(sb);
+                e.getKey().toCQL(sb, formatter);
+                // when a AssignmentOperator the `=` is added there so don't add
+                //TODO this is super hacky...
+                if (!(e.getValue() instanceof AssignmentOperator))
+                    sb.append('=');
+                e.getValue().toCQL(sb, formatter);
+                sb.append(",");
+            }
+            sb.setLength(sb.length() - 1); // 1 = ","
+            formatter.endgroup(sb);
+            // WHERE row_specification
+            toCQLWhere(this.where, sb, formatter);
+            // [IF EXISTS | IF condition [AND condition] . . .] ;
+            if (casCondition.isPresent())
+            {
+                formatter.section(sb);
+                casCondition.get().toCQL(sb, formatter);
+            }
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            List<Element> elements = new ArrayList<>(2 +
+                                                     (using.isPresent() ? 1 : 0) +
+                                                     (casCondition.isPresent() ? 1 : 0) +
+                                                     (set.size() * 2));
+            elements.add(table);
+            if (using.isPresent())
+                elements.add(using.get());
+            for (var e : set.entrySet())
+            {
+                elements.add(e.getKey());
+                elements.add(e.getValue());
+            }
+            elements.add(where);
+            if (casCondition.isPresent())
+                elements.add(casCondition.get());
+            return elements.stream();
+        }
+
+        @Override
+        public Statement visit(Visitor v)
+        {
+            var u = v.visit(this);
+            if (u != this) return u;
+            boolean updated = false;
+            LinkedHashMap<Symbol, Expression> copied = new LinkedHashMap<>(set.size());
+            for (var e : set.entrySet())
+            {
+                Symbol s = e.getKey().visit("UPDATE", v);
+                if (s != e.getKey())
+                    updated = true;
+                Expression ex = e.getValue().visit(v);
+                if (e.getValue() != ex)
+                    updated = true;
+                copied.put(s, ex);
+            }
+            Conditional copiedWhere = where.visit(v);
+            if (where != copiedWhere)
+                updated = true;
+
+            if (!updated) return this;
+            return new Update(table, using, copied, copiedWhere, casCondition);
+        }
+
+        @Override
+        public boolean isCas()
+        {
+            return casCondition.isPresent();
+        }
+    }
+
+    public static class Delete extends Mutation
+    {
+        public final List<Symbol> columns;
+        public final Optional<Timestamp> using;
+        public final Conditional where;
+        public final Optional<? extends CasCondition> casCondition;
+
+        public Delete(List<Symbol> columns,
+                      TableReference table,
+                      Optional<Timestamp> using,
+                      Conditional where,
+                      Optional<? extends CasCondition> casCondition)
+        {
+            super(Mutation.Kind.DELETE, table);
+            this.columns = columns;
+            this.using = using;
+            this.where = where;
+            this.casCondition = casCondition;
+        }
+
+        /*
+DELETE [column_name (term)][, ...]
+FROM [keyspace_name.] table_name
+[USING TIMESTAMP timestamp_value]
+WHERE PK_column_conditions
+[IF EXISTS | IF static_column_conditions]
+         */
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            // DELETE [column_name (term)][, ...]
+            sb.append("DELETE");
+            if (!columns.isEmpty())
+            {
+                sb.append(" ");
+                for (var c : columns)
+                {
+                    c.toCQL(sb, formatter);
+                    sb.append(", ");
+                }
+                sb.setLength(sb.length() - 2); // 2 = ", "
+            }
+            // FROM [keyspace_name.] table_name
+            formatter.section(sb);
+            sb.append("FROM ");
+            table.toCQL(sb, formatter);
+            // [USING TIMESTAMP timestamp_value]
+            if (using.isPresent())
+            {
+                formatter.section(sb);
+                using.get().toCQL(sb, formatter);
+            }
+            // WHERE PK_column_conditions
+            toCQLWhere(this.where, sb, formatter);
+            // [IF EXISTS | IF static_column_conditions]
+            if (casCondition.isPresent())
+            {
+                formatter.section(sb);
+                casCondition.get().toCQL(sb, formatter);
+            }
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            List<Element> elements = new ArrayList<>(columns.size() + 4);
+            elements.addAll(columns);
+            elements.add(table);
+            if (using.isPresent())
+                elements.add(using.get());
+            elements.add(where);
+            if (casCondition.isPresent())
+                elements.add(casCondition.get());
+            return elements.stream();
+        }
+
+        @Override
+        public Statement visit(Visitor v)
+        {
+            var u = v.visit(this);
+            if (u != this)
+                return u;
+            boolean updated = false;
+            List<Symbol> copiedColumns = new ArrayList<>(columns.size());
+            for (var s : columns)
+            {
+                var s2 = s.visit("DELETE", v);
+                if (s != s2)
+                    updated = true;
+                copiedColumns.add(s2);
+            }
+            var copiedWhere = where.visit(v);
+            if (copiedWhere != where)
+                updated = true;
+
+            if (!updated) return this;
+            return new Delete(copiedColumns, table, using, copiedWhere, casCondition);
+        }
+
+        @Override
+        public boolean isCas()
+        {
+            return casCondition.isPresent();
+        }
+    }
+
+    public static abstract class BaseBuilder<T, B extends BaseBuilder<T, B>>
+    {
+        private final Kind kind;
+        private final TableMetadata metadata;
+        protected final LinkedHashSet<Symbol> partitionColumns, clusteringColumns, primaryColumns, regularAndStatic, allColumns;
+        private boolean includeKeyspace = true;
+        private final Set<Symbol> neededPks = new HashSet<>();
+
+        protected BaseBuilder(Kind kind, TableMetadata table)
+        {
+            this.kind = kind;
+            this.metadata = table;
+
+            // partition key is always required, so validate
+            this.partitionColumns = toSet(table.partitionKeyColumns());
+            this.clusteringColumns = toSet(table.clusteringColumns());
+            this.primaryColumns = new LinkedHashSet<>();
+            this.primaryColumns.addAll(partitionColumns);
+            this.primaryColumns.addAll(clusteringColumns);
+            this.regularAndStatic = new LinkedHashSet<>();
+            this.regularAndStatic.addAll(toSet(table.regularAndStaticColumns()));
+            this.allColumns = toSet(safeColumns(table));
+            neededPks.addAll(partitionColumns);
+        }
+
+        public static List<ColumnMetadata> safeColumns(TableMetadata metadata)
+        {
+            List<ColumnMetadata> columns = new ArrayList<>(metadata.columns().size());
+            metadata.allColumnsInSelectOrder().forEachRemaining(columns::add);
+            return columns;
+        }
+
+        public abstract T build();
+
+        protected void assertAllPksHaveEq()
+        {
+            if (neededPks.isEmpty())
+                return;
+            throw new IllegalStateException("Attempted to create a " + kind + " but not all partition columns have an equality condition; missing " + neededPks);
+        }
+
+        protected void maybePkEq(Expression symbol)
+        {
+            if (symbol instanceof Symbol)
+                pkEq((Symbol) symbol);
+        }
+
+        private void pkEq(Symbol symbol)
+        {
+            neededPks.remove(symbol);
+        }
+
+        public B includeKeyspace(boolean value)
+        {
+            this.includeKeyspace = value;
+            return (B) this;
+        }
+
+        public B includeKeyspace()
+        {
+            return includeKeyspace(true);
+        }
+
+        public B excludeKeyspace()
+        {
+            return includeKeyspace(false);
+        }
+
+        protected TableReference tableRef()
+        {
+            return includeKeyspace ? TableReference.from(metadata) : new TableReference(metadata.name);
+        }
+    }
+
+    public static class InsertBuilder extends BaseBuilder<Insert, InsertBuilder> implements Conditional.EqBuilder<InsertBuilder>
+    {
+        private final LinkedHashMap<Symbol, Expression> values = new LinkedHashMap<>();
+        private boolean ifNotExists = false;
+        private @Nullable TTL ttl;
+        private @Nullable Timestamp timestamp;
+
+        protected InsertBuilder(TableMetadata table)
+        {
+            super(Kind.INSERT, table);
+        }
+
+        public InsertBuilder ifNotExists()
+        {
+            ifNotExists = true;
+            return this;
+        }
+
+        public InsertBuilder timestamp(Value value)
+        {
+            this.timestamp = new Timestamp(value);
+            return this;
+        }
+
+        public InsertBuilder ttl(Value value)
+        {
+            this.ttl = new TTL(value);
+            return this;
+        }
+
+        public InsertBuilder ttl(int value)
+        {
+            return ttl(Bind.of(value));
+        }
+
+        @Override
+        public InsertBuilder value(Symbol ref, Expression e)
+        {
+            maybePkEq(ref);
+            values.put(ref, e);
+            return this;
+        }
+
+        @Override
+        public Insert build()
+        {
+            assertAllPksHaveEq();
+            return new Insert(tableRef(),
+                              new LinkedHashMap<>(values),
+                              ifNotExists,
+                              (ttl == null && timestamp == null) ? Optional.empty() : Optional.of(new Using(Optional.ofNullable(ttl), Optional.ofNullable(timestamp))));
+        }
+    }
+
+    public static class UpdateBuilder extends BaseBuilder<Update, UpdateBuilder> implements Conditional.ConditionalBuilder<UpdateBuilder>
+    {
+        private @Nullable TTL ttl;
+        private @Nullable Timestamp timestamp;
+        private final LinkedHashMap<Symbol, Expression> set = new LinkedHashMap<>();
+        private final Conditional.Builder where = new Conditional.Builder();
+        private @Nullable CasCondition casCondition;
+
+        protected UpdateBuilder(TableMetadata table)
+        {
+            super(Kind.UPDATE, table);
+        }
+
+        public UpdateBuilder timestamp(Value value)
+        {
+            this.timestamp = new Timestamp(value);
+            return this;
+        }
+
+        public UpdateBuilder ttl(Value value)
+        {
+            this.ttl = new TTL(value);
+            return this;
+        }
+
+        public UpdateBuilder ttl(int value)
+        {
+            return ttl(Bind.of(value));
+        }
+
+        public UpdateBuilder ifExists()
+        {
+            casCondition = CasCondition.Simple.Exists;
+            return this;
+        }
+
+        public UpdateBuilder ifCondition(CasCondition condition)
+        {
+            casCondition = condition;
+            return this;
+        }
+
+        public UpdateBuilder set(Symbol column, Expression value)
+        {
+            if (!regularAndStatic.contains(column))
+                throw new IllegalArgumentException("Attempted to set a non regular or static column " + column + "; expected " + regularAndStatic);
+            set.put(column, value);
+            return this;
+        }
+
+        public UpdateBuilder set(String column, int value)
+        {
+            return set(new Symbol(column, Int32Type.instance), Bind.of(value));
+        }
+
+        @Override
+        public UpdateBuilder where(Expression ref, Conditional.Where.Inequality kind, Expression expression)
+        {
+            if (kind == Conditional.Where.Inequality.EQUAL)
+                maybePkEq(ref);
+            where.where(ref, kind, expression);
+            return this;
+        }
+
+        @Override
+        public UpdateBuilder between(Expression ref, Expression start, Expression end)
+        {
+            where.between(ref, start, end);
+            return this;
+        }
+
+        @Override
+        public UpdateBuilder in(ReferenceExpression ref, List<? extends Expression> expressions)
+        {
+            maybePkEq(ref);
+            where.in(ref, expressions);
+            return this;
+        }
+
+        @Override
+        public UpdateBuilder is(Symbol ref, Conditional.Is.Kind kind)
+        {
+            where.is(ref, kind);
+            return this;
+        }
+
+        @Override
+        public Update build()
+        {
+            assertAllPksHaveEq();
+            if (set.isEmpty())
+                throw new IllegalStateException("Unable to create an Update without a SET section; set function was never called");
+
+            return new Update(tableRef(),
+                              (ttl == null && timestamp == null) ? Optional.empty() : Optional.of(new Using(Optional.ofNullable(ttl), Optional.ofNullable(timestamp))),
+                              new LinkedHashMap<>(set),
+                              where.build(),
+                              Optional.ofNullable(casCondition));
+        }
+    }
+
+    public static class DeleteBuilder extends BaseBuilder<Delete, DeleteBuilder> implements Conditional.ConditionalBuilder<DeleteBuilder>
+    {
+        private final List<Symbol> columns = new ArrayList<>();
+        private @Nullable Timestamp timestamp = null;
+        private final Conditional.Builder where = new Conditional.Builder();
+        //TODO (now): casCondition
+        private @Nullable CasCondition casCondition;
+
+        public DeleteBuilder(TableMetadata table)
+        {
+            super(Kind.DELETE, table);
+        }
+
+        public DeleteBuilder ifExists()
+        {
+            casCondition = CasCondition.Simple.Exists;
+            return this;
+        }
+
+        public DeleteBuilder ifCondition(CasCondition condition)
+        {
+            casCondition = condition;
+            return this;
+        }
+
+        public List<Symbol> columns()
+        {
+            return Collections.unmodifiableList(columns);
+        }
+
+        public DeleteBuilder column(Symbol symbol)
+        {
+            if (!regularAndStatic.contains(symbol))
+                throw new IllegalArgumentException("Can not delete column " + symbol + "; only regular/static columns can be deleted, expected " + regularAndStatic);
+            columns.add(symbol);
+            return this;
+        }
+
+        public DeleteBuilder column(Symbol... symbols)
+        {
+            return column(Arrays.asList(symbols));
+        }
+
+        public DeleteBuilder column(List<Symbol> symbols)
+        {
+            symbols.forEach(this::column);
+            return this;
+        }
+
+        public DeleteBuilder timestamp(Value value)
+        {
+            this.timestamp = new Timestamp(value);
+            return this;
+        }
+
+        @Override
+        public DeleteBuilder where(Expression ref, Conditional.Where.Inequality kind, Expression expression)
+        {
+            if (kind == Conditional.Where.Inequality.EQUAL)
+                maybePkEq(ref);
+            where.where(ref, kind, expression);
+            return this;
+        }
+
+        @Override
+        public DeleteBuilder between(Expression ref, Expression start, Expression end)
+        {
+            where.between(ref, start, end);
+            return this;
+        }
+
+        @Override
+        public DeleteBuilder in(ReferenceExpression ref, List<? extends Expression> expressions)
+        {
+            maybePkEq(ref);
+            where.in(ref, expressions);
+            return this;
+        }
+
+        @Override
+        public DeleteBuilder is(Symbol ref, Conditional.Is.Kind kind)
+        {
+            where.is(ref, kind);
+            return this;
+        }
+
+        @Override
+        public Delete build()
+        {
+            assertAllPksHaveEq();
+            return new Delete(columns.isEmpty() ? Collections.emptyList() : new ArrayList<>(columns),
+                              tableRef(),
+                              Optional.ofNullable(timestamp),
+                              where.build(),
+                              Optional.ofNullable(casCondition));
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Operator.java b/test/unit/org/apache/cassandra/cql3/ast/Operator.java
new file mode 100644
index 0000000000..d0baa10ec0
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Operator.java
@@ -0,0 +1,106 @@
+/*
+ * 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.ast;
+
+import java.util.EnumSet;
+import java.util.stream.Stream;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.NumberType;
+import org.apache.cassandra.db.marshal.StringType;
+import org.apache.cassandra.db.marshal.TemporalType;
+
+public class Operator implements Expression
+{
+    public enum Kind
+    {
+        ADD('+'),
+        SUBTRACT('-'),
+        MULTIPLY('*');
+        //TODO support avoiding 42 / 0 and 42 % 0 as these will fail
+//        DIVIDE('/'),
+//        MOD('%');
+
+        private final char value;
+
+        Kind(char value)
+        {
+            this.value = value;
+        }
+    }
+
+    public final Kind kind;
+    public final Expression left;
+    public final Expression right;
+
+    public Operator(Kind kind, Expression left, Expression right)
+    {
+        this.kind = kind;
+        this.left = left;
+        this.right = right;
+        if (!left.type().equals(right.type()))
+            throw new IllegalArgumentException("Types do not match: left=" + left.type() + ", right=" + right.type());
+    }
+
+    public static EnumSet<Kind> supportsOperators(AbstractType<?> type)
+    {
+        //TODO (coverage): some operators exist but only for different inputs than (type, type)
+        // date and time both support +- duration; aka date + duration = date, time - duration = time (looks like all temporal types are this way)
+        type = type.unwrap();
+        // see org.apache.cassandra.cql3.functions.OperationFcts.OPERATION
+        if (type instanceof NumberType)
+            return EnumSet.allOf(Kind.class);
+        if (type instanceof TemporalType)
+            return EnumSet.noneOf(Kind.class); //TODO (coverage): not true, but requires the RHS to be duration type only
+        if (type instanceof StringType)
+            return EnumSet.of(Kind.ADD);
+        return EnumSet.noneOf(Kind.class);
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return left.type();
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        left.toCQL(sb, formatter);
+        sb.append(' ').append(kind.value).append(' ');
+        right.toCQL(sb, formatter);
+    }
+
+    @Override
+    public Stream<? extends Element> stream()
+    {
+        return Stream.of(left, right);
+    }
+
+    @Override
+    public Expression visit(Visitor v)
+    {
+        var u = v.visit(this);
+        if (u != this) return u;
+        var left = this.left.visit(v);
+        var right = this.right.visit(v);
+        if (left == this.left && right == this.right) return this;
+        return new Operator(kind, left, right);
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Reference.java b/test/unit/org/apache/cassandra/cql3/ast/Reference.java
new file mode 100644
index 0000000000..fe836a05dd
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Reference.java
@@ -0,0 +1,182 @@
+/*
+ * 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.ast;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import com.google.common.collect.ImmutableList;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.BytesType;
+
+public class Reference implements ReferenceExpression
+{
+    public final List<ReferenceExpression> path;
+
+    private Reference(List<ReferenceExpression> path)
+    {
+        if (path.isEmpty())
+            throw new IllegalArgumentException("Reference may not be empty");
+        this.path = path;
+        if (path.stream().anyMatch(e -> e.toCQL().contains("\"\"")))
+            new Throwable().printStackTrace();
+    }
+
+    public static Reference of(ReferenceExpression top)
+    {
+        return new Reference(Collections.singletonList(Objects.requireNonNull(top)));
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return path.get(path.size() - 1).type();
+    }
+
+    public Builder unbuild()
+    {
+        return new Builder(path);
+    }
+
+    public Reference add(String symbol, AbstractType<?> subType)
+    {
+        return unbuild().add(symbol, subType).build();
+    }
+
+    public Reference add(Expression expression)
+    {
+        if (expression instanceof Reference)
+        {
+            Reference other = (Reference) expression;
+            List<ReferenceExpression> path = new ArrayList<>(this.path.size() + other.path.size());
+            path.addAll(this.path);
+            path.addAll(other.path);
+            return new Reference(path);
+        }
+        else if (expression instanceof Symbol)
+        {
+            return unbuild().add((Symbol) expression).build();
+        }
+        else
+        {
+            return unbuild().add(expression.name(), expression.type()).build();
+        }
+    }
+
+    public Reference lastAsCollection(Function<ReferenceExpression, CollectionAccess> fn)
+    {
+        List<ReferenceExpression> path = new ArrayList<>(this.path.size());
+        for (int i = 0; i < this.path.size() - 1; i++)
+            path.add(this.path.get(i));
+        ReferenceExpression last = this.path.get(this.path.size() - 1);
+        path.add(Objects.requireNonNull(fn.apply(last)));
+        return new Reference(path);
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        path.forEach(p -> {
+            p.toCQL(sb, formatter);
+            sb.append('.');
+        });
+        sb.setLength(sb.length() - 1); // last .
+    }
+
+    @Override
+    public Stream<? extends Element> stream()
+    {
+        return path.stream();
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Reference elements = (Reference) o;
+        return Objects.equals(path, elements.path);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return Objects.hash(path);
+    }
+
+    @Override
+    public String toString()
+    {
+        return toCQL();
+    }
+
+    public static class Builder
+    {
+        private final List<ReferenceExpression> path;
+
+        public Builder()
+        {
+            path = new ArrayList<>();
+        }
+
+        private Builder(List<ReferenceExpression> path)
+        {
+            this.path = new ArrayList<>(path.size() + 1);
+            this.path.addAll(path);
+        }
+
+        public Builder add(String name)
+        {
+            return add(new Symbol(name, BytesType.instance));
+        }
+
+        public Builder add(String symbol, AbstractType<?> subType)
+        {
+            return add(new Symbol(symbol, subType));
+        }
+
+        public Builder add(Symbol symbol)
+        {
+            path.add(Objects.requireNonNull(symbol));
+            return this;
+        }
+
+        public Builder add(String[] paths)
+        {
+            for (String name : paths)
+                path.add(new Symbol(name, BytesType.instance));
+            return this;
+        }
+
+        public Reference build()
+        {
+            switch (path.size())
+            {
+                case 0: throw new IllegalStateException("Unable to make a Reference from an empty path");
+                case 1: return Reference.of(path.get(0));
+                default: return new Reference(ImmutableList.copyOf(path));
+            }
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/ReferenceExpression.java b/test/unit/org/apache/cassandra/cql3/ast/ReferenceExpression.java
new file mode 100644
index 0000000000..7a99e030a4
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/ReferenceExpression.java
@@ -0,0 +1,31 @@
+/*
+ * 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.ast;
+
+/**
+ * Marker interface of what is allowed in a reference
+ */
+public interface ReferenceExpression extends Expression
+{
+    @Override
+    default ReferenceExpression visit(Visitor v)
+    {
+        return this;
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Select.java b/test/unit/org/apache/cassandra/cql3/ast/Select.java
new file mode 100644
index 0000000000..224c8f6d56
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Select.java
@@ -0,0 +1,417 @@
+/*
+ * 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.ast;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+
+import com.google.common.collect.ImmutableList;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.schema.TableMetadata;
+
+public class Select implements Statement
+{
+    /*
+SELECT * | select_expression | DISTINCT partition //TODO DISTINCT
+FROM [keyspace_name.] table_name
+[WHERE partition_value
+   [AND clustering_filters
+   [AND static_filters]]]
+[ORDER BY PK_column_name ASC|DESC]
+[LIMIT N]
+[ALLOW FILTERING]
+     */
+    // select
+    public final List<Expression> selections;
+    // from
+    public final Optional<TableReference> source;
+    // where
+    public final Optional<Conditional> where;
+    public final Optional<OrderBy> orderBy;
+    public final Optional<Value> limit;
+    public final boolean allowFiltering;
+
+    public Select(List<Expression> selections)
+    {
+        this(selections, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty());
+    }
+
+    public Select(List<Expression> selections, Optional<TableReference> source, Optional<Conditional> where, Optional<OrderBy> orderBy, Optional<Value> limit)
+    {
+        this(selections, source, where, orderBy, limit, false);
+    }
+
+    public Select(List<Expression> selections, Optional<TableReference> source, Optional<Conditional> where, Optional<OrderBy> orderBy, Optional<Value> limit, boolean allowFiltering)
+    {
+        this.selections = selections;
+        this.source = source;
+        this.where = where;
+        this.orderBy = orderBy;
+        this.limit = limit;
+        this.allowFiltering = allowFiltering;
+
+        if (!source.isPresent())
+        {
+            if (where.isPresent())
+                throw new IllegalArgumentException("Can not have a WHERE clause when there isn't a FROM");
+            if (orderBy.isPresent())
+                throw new IllegalArgumentException("Can not have a ORDER BY clause when there isn't a FROM");
+            if (limit.isPresent())
+                throw new IllegalArgumentException("Can not have a LIMIT clause when there isn't a FROM");
+            if (allowFiltering)
+                throw new IllegalArgumentException("Can not have a ALLOW FILTERING clause when there isn't a FROM");
+        }
+    }
+
+    public static Builder builder()
+    {
+        return new Builder();
+    }
+
+    public Select withAllowFiltering()
+    {
+        return new Select(selections, source, where, orderBy, limit, true);
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        sb.append("SELECT ");
+        if (selections.isEmpty())
+        {
+            sb.append('*');
+        }
+        else
+        {
+            selections.forEach(s -> {
+                s.toCQL(sb, formatter);
+                sb.append(", ");
+            });
+            sb.setLength(sb.length() - 2); // last ', '
+        }
+        if (source.isPresent())
+        {
+            formatter.section(sb);
+            sb.append("FROM ");
+            source.get().toCQL(sb, formatter);
+            if (where.isPresent())
+            {
+                formatter.section(sb);
+                sb.append("WHERE ");
+                where.get().toCQL(sb, formatter);
+            }
+            if (orderBy.isPresent())
+            {
+                formatter.section(sb);
+                sb.append("ORDER BY ");
+                orderBy.get().toCQL(sb, formatter);
+            }
+            if (limit.isPresent())
+            {
+                formatter.section(sb);
+                sb.append("LIMIT ");
+                limit.get().toCQL(sb, formatter);
+            }
+
+            if (allowFiltering)
+            {
+                formatter.section(sb);
+                sb.append("ALLOW FILTERING");
+            }
+        }
+    }
+
+    @Override
+    public Stream<? extends Element> stream()
+    {
+        List<Element> es = new ArrayList<>(selections.size()
+                                           + (source.isPresent() ? 1 : 0)
+                                           + (where.isPresent() ? 1 : 0)
+                                           + (orderBy.isPresent() ? 1 : 0)
+                                           + (limit.isPresent() ? 1 : 0));
+        es.addAll(selections);
+        if (source.isPresent())
+            es.add(source.get());
+        if (where.isPresent())
+            es.add(where.get());
+        if (orderBy.isPresent())
+            es.add(orderBy.get());
+        if (limit.isPresent())
+            es.add(limit.get());
+        return es.stream();
+    }
+
+    @Override
+    public String toString()
+    {
+        return detailedToString();
+    }
+
+    @Override
+    public Kind kind()
+    {
+        return Kind.SELECT;
+    }
+
+    @Override
+    public Statement visit(Visitor v)
+    {
+        var u = v.visit(this);
+        if (u != this) return u;
+        boolean updated = false;
+        List<Expression> selections = new ArrayList<>(this.selections.size());
+        for (Expression e : this.selections)
+        {
+            Expression update = e.visit(v);
+            updated |= e != update;
+            selections.add(update);
+        }
+        Optional<Conditional> where;
+        if (this.where.isPresent())
+        {
+            var c = this.where.get();
+            var update = c.visit(v);
+            updated |= c != update;
+            where = Optional.ofNullable(update);
+        }
+        else
+        {
+            where = this.where;
+        }
+        Optional<Value> limit;
+        if (this.limit.isPresent())
+        {
+            var l = this.limit.get();
+            var update = l.visit(v);
+            updated |= l != update;
+            limit = Optional.ofNullable(update);
+        }
+        else
+        {
+            limit = this.limit;
+        }
+        if (!updated) return this;
+        return new Select(selections, source, where, orderBy, limit, allowFiltering);
+    }
+
+    public static class OrderBy implements Element
+    {
+        public enum Ordering
+        {ASC, DESC}
+
+        public final List<Ordered> ordered;
+
+        public OrderBy(List<Ordered> ordered)
+        {
+            if (ordered.isEmpty())
+                throw new IllegalArgumentException("Can not ORDER BY an empty list");
+            this.ordered = ordered;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            if (ordered.size() == 1)
+            {
+                ordered.get(0).toCQL(sb, formatter);
+                return;
+            }
+
+            String postfix = ", ";
+            for (Ordered o : ordered)
+            {
+                o.toCQL(sb, formatter);
+                sb.append(postfix);
+            }
+            sb.setLength(sb.length() - postfix.length());
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return ordered.stream();
+        }
+
+        public static class Ordered implements Element
+        {
+            public final ReferenceExpression expression;
+            public final Ordering ordering;
+
+            public Ordered(ReferenceExpression expression, Ordering ordering)
+            {
+                this.expression = expression;
+                this.ordering = ordering;
+            }
+
+            @Override
+            public void toCQL(StringBuilder sb, CQLFormatter formatter)
+            {
+                expression.toCQL(sb, formatter);
+                sb.append(' ');
+                sb.append(ordering.name());
+            }
+
+            @Override
+            public Stream<? extends Element> stream()
+            {
+                return Stream.of(expression);
+            }
+        }
+
+        public static class Builder
+        {
+            private final List<Ordered> ordered = new ArrayList<>();
+
+            public boolean isEmpty()
+            {
+                return ordered.isEmpty();
+            }
+
+            public Builder add(ReferenceExpression expression, Ordering ordering)
+            {
+                ordered.add(new Ordered(expression, ordering));
+                return this;
+            }
+
+            public OrderBy build()
+            {
+                return new OrderBy(ImmutableList.copyOf(ordered));
+            }
+        }
+    }
+
+    public static class Builder implements Conditional.ConditionalBuilder<Builder>
+    {
+        @Nullable // null means wildcard
+        private List<Expression> selections = new ArrayList<>();
+        private Optional<TableReference> source = Optional.empty();
+        private Conditional.Builder where = new Conditional.Builder();
+        private OrderBy.Builder orderBy = new OrderBy.Builder();
+        private Optional<Value> limit = Optional.empty();
+        private boolean allowFiltering = false;
+
+        public Builder wildcard()
+        {
+            if (selections != null && !selections.isEmpty())
+                throw new IllegalStateException("Attempted to use * for selection but existing selections exist: " + selections);
+            selections = null;
+            return this;
+        }
+
+        public Builder columnSelection(String name, AbstractType<?> type)
+        {
+            return selection(new Symbol(name, type));
+        }
+
+        public Builder allowFiltering()
+        {
+            allowFiltering = true;
+            return this;
+        }
+
+        public Builder selection(Expression e)
+        {
+            if (selections == null)
+                throw new IllegalStateException("Unable to add '" + e.name() + "' as a selection as * was already requested");
+            selections.add(e);
+            return this;
+        }
+
+        public Builder table(TableReference ref)
+        {
+            source = Optional.of(ref);
+            return this;
+        }
+
+        public Builder table(String ks, String name)
+        {
+            return table(new TableReference(Optional.of(ks), name));
+        }
+
+        public Builder table(String name)
+        {
+            return table(new TableReference(name));
+        }
+
+        public Builder table(TableMetadata table)
+        {
+            return table(TableReference.from(table));
+        }
+
+        @Override
+        public Builder where(Expression ref, Conditional.Where.Inequality kind, Expression expression)
+        {
+            where.where(ref, kind, expression);
+            return this;
+        }
+
+        @Override
+        public Builder between(Expression ref, Expression start, Expression end)
+        {
+            where.between(ref, start, end);
+            return this;
+        }
+
+        @Override
+        public Builder in(ReferenceExpression ref, List<? extends Expression> expressions)
+        {
+            where.in(ref, expressions);
+            return this;
+        }
+
+        @Override
+        public Builder is(Symbol ref, Conditional.Is.Kind kind)
+        {
+            where.is(ref, kind);
+            return this;
+        }
+
+        public Builder orderByColumn(String name, AbstractType<?> type, OrderBy.Ordering ordering)
+        {
+            orderBy.add(new Symbol(name, type), ordering);
+            return this;
+        }
+
+        public Builder limit(Value limit)
+        {
+            this.limit = Optional.of(limit);
+            return this;
+        }
+
+        public Builder limit(int limit)
+        {
+            return limit(Bind.of(limit));
+        }
+
+        public Select build()
+        {
+            return new Select((selections == null || selections.isEmpty()) ? Collections.emptyList() : ImmutableList.copyOf(selections),
+                              source,
+                              where.isEmpty() ? Optional.empty() : Optional.of(where.build()),
+                              orderBy.isEmpty() ? Optional.empty() : Optional.of(orderBy.build()),
+                              limit,
+                              allowFiltering);
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/StandardVisitors.java b/test/unit/org/apache/cassandra/cql3/ast/StandardVisitors.java
new file mode 100644
index 0000000000..2a76cd8097
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/StandardVisitors.java
@@ -0,0 +1,50 @@
+/*
+ * 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.ast;
+
+public class StandardVisitors
+{
+    public static final Visitor BIND_TO_LITERAL = new Visitor()
+    {
+        @Override
+        public Value visit(Value v)
+        {
+            if (!(v instanceof Bind)) return v;
+            Bind b = (Bind) v;
+            return new Literal(b.value(), b.type());
+        }
+    };
+
+    public static final Visitor UNWRAP_TYPE_HINT = new Visitor()
+    {
+        @Override
+        public Expression visit(Expression e)
+        {
+            if (!(e instanceof TypeHint)) return e;
+            TypeHint hint = (TypeHint) e;
+            if (hint.type.equals(hint.e.type()))
+                return hint.e;
+            return e;
+        }
+    };
+
+    public static final Visitor.CompositeVisitor DEBUG = Visitor.CompositeVisitor.of(UNWRAP_TYPE_HINT, BIND_TO_LITERAL);
+
+    private StandardVisitors() {}
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Statement.java b/test/unit/org/apache/cassandra/cql3/ast/Statement.java
new file mode 100644
index 0000000000..ffcba03465
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Statement.java
@@ -0,0 +1,110 @@
+/*
+ * 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.ast;
+
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.apache.cassandra.utils.ByteBufferUtil;
+
+public interface Statement extends Element
+{
+    enum Kind { SELECT, MUTATION, TXN }
+    Kind kind();
+
+    default Object[] binds()
+    {
+        return streamRecursive()
+               .filter(e -> e instanceof Bind)
+               .map(e -> ((Bind) e).value())
+               .toArray(Object[]::new);
+    }
+
+    default ByteBuffer[] bindsEncoded()
+    {
+        return streamRecursive()
+               .filter(e -> e instanceof Bind)
+               .map(e -> ((Bind) e).valueEncoded())
+               .toArray(ByteBuffer[]::new);
+    }
+
+    default String detailedToString()
+    {
+        Object[] binds = binds();
+        return "CQL:\n" + toCQL() + "\nBinds:\n" + IntStream.range(0, binds.length)
+                                                            .mapToObj(i -> i + " -> " + binds[i].getClass().getCanonicalName() + "(" + normalize(binds[i]) + ")")
+                                                            .collect(Collectors.joining("\n"));
+    }
+
+    Statement visit(Visitor v);
+
+    static boolean hasByteBuffer(Object value)
+    {
+        if (value == null)
+            return false;
+        if (value instanceof ByteBuffer)
+            return true;
+        else if (value instanceof Collection)
+        {
+            Collection<Object> collection = (Collection<Object>) value;
+            return collection.stream().anyMatch(Statement::hasByteBuffer);
+        }
+        else if (value instanceof Map)
+        {
+            Map<Object, Object> map = (Map<Object, Object>) value;
+            return map.entrySet().stream().anyMatch(e -> hasByteBuffer(e.getKey()) || hasByteBuffer(e.getValue()));
+        }
+        return false;
+    }
+
+    static String normalize(Object value)
+    {
+        if (value == null)
+            return null;
+        if (value instanceof ByteBuffer)
+        {
+            ByteBuffer bb = (ByteBuffer) value;
+            if (bb.remaining() > 100)
+            {
+                bb = bb.duplicate();
+                bb.limit(bb.position() + 100);
+                return ByteBufferUtil.bytesToHex(bb) + "...";
+            }
+            return ByteBufferUtil.bytesToHex(bb);
+        }
+        else if (value instanceof Collection)
+        {
+            Collection<Object> collection = (Collection<Object>) value;
+            if (hasByteBuffer(collection))
+                return collection.stream().map(Statement::normalize).collect(Collectors.toList()).toString();
+        }
+        else if (value instanceof Map)
+        {
+            Map<Object, Object> map = (Map<Object, Object>) value;
+            if (hasByteBuffer(map))
+                return map.entrySet().stream()
+                          .map(e -> normalize(e.getKey()) + " -> " + normalize(e.getValue()))
+                          .collect(Collectors.joining(", ", "{", "}"));
+        }
+        return value.toString();
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Symbol.java b/test/unit/org/apache/cassandra/cql3/ast/Symbol.java
new file mode 100644
index 0000000000..f905218c45
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Symbol.java
@@ -0,0 +1,182 @@
+/*
+ * 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.ast;
+
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.cassandra.cql3.ColumnIdentifier;
+import org.apache.cassandra.cql3.ReservedKeywords;
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.BytesType;
+import org.apache.cassandra.db.marshal.ReversedType;
+import org.apache.cassandra.schema.ColumnMetadata;
+
+public class Symbol implements ReferenceExpression, Comparable<Symbol>
+{
+    public final String symbol;
+    private final AbstractType<?> type;
+    public final boolean reversed;
+
+    public Symbol(ColumnMetadata column)
+    {
+        this(column.name.toString(), column.type);
+    }
+
+    public Symbol(String symbol, AbstractType<?> type)
+    {
+        this.symbol = Objects.requireNonNull(symbol);
+        this.type = Objects.requireNonNull(type).unwrap();
+        this.reversed = type.isReversed();
+    }
+
+    public static Symbol from(ColumnMetadata metadata)
+    {
+        return new Symbol(metadata.name.toString(), metadata.type);
+    }
+
+    public static Symbol unknownType(String name)
+    {
+        return new Symbol(name, BytesType.instance);
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        maybeQuote(sb, symbol);
+    }
+
+    public static void maybeQuote(StringBuilder sb, String symbol)
+    {
+        sb.append(maybeQuote(symbol));
+    }
+
+    public static String maybeQuote(String symbol)
+    {
+        if (ReservedKeywords.isReserved(symbol))
+        {
+            return quote(symbol);
+        }
+        else
+        {
+            return ColumnIdentifier.maybeQuote(symbol);
+        }
+    }
+
+    private static final Pattern PATTERN_DOUBLE_QUOTE = Pattern.compile("\"", Pattern.LITERAL);
+    private static final String ESCAPED_DOUBLE_QUOTE = Matcher.quoteReplacement("\"\"");
+
+    //TODO refactor ColumnIdentifier to expose this
+    private static String quote(String text)
+    {
+        return '"' + PATTERN_DOUBLE_QUOTE.matcher(text).replaceAll(ESCAPED_DOUBLE_QUOTE) + '"';
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return type;
+    }
+
+    public AbstractType<?> rawType()
+    {
+        if (reversed) return ReversedType.getInstance(type);
+        return type;
+    }
+
+    @Override
+    public String name()
+    {
+        return symbol;
+    }
+
+    /**
+     * Same as {@link #visit(Visitor)} but has the restriction that the return type may not be changed away from {@link Symbol}.
+     */
+    public Symbol visit(String type, Visitor v)
+    {
+        var u = v.visit(this);
+        if (u == this) return this;
+        if (!(u instanceof Symbol))
+            throw new IllegalStateException("Visitor converted a Symbol to " + u.getClass().getSimpleName() + "; this is not supported with " + type);
+        return (Symbol) u;
+    }
+
+    public String detailedName()
+    {
+        return symbol + " " + type.asCQL3Type() + (reversed ? " (reversed)" : "");
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Symbol symbol1 = (Symbol) o;
+        return Objects.equals(symbol, symbol1.symbol);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return Objects.hash(symbol);
+    }
+
+    @Override
+    public String toString()
+    {
+        return toCQL();
+    }
+
+    @Override
+    public int compareTo(Symbol o)
+    {
+        return toCQL().compareTo(o.toCQL());
+    }
+
+    public static class UnquotedSymbol extends Symbol
+    {
+        public UnquotedSymbol(String symbol, AbstractType<?> type)
+        {
+            super(symbol, type);
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            sb.append(symbol);
+        }
+
+        @Override
+        public boolean equals(Object o)
+        {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            UnquotedSymbol symbol1 = (UnquotedSymbol) o;
+            return Objects.equals(symbol, symbol1.symbol);
+        }
+
+        @Override
+        public int hashCode()
+        {
+            return Objects.hash(symbol);
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/TableReference.java b/test/unit/org/apache/cassandra/cql3/ast/TableReference.java
new file mode 100644
index 0000000000..c2d01200b4
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/TableReference.java
@@ -0,0 +1,57 @@
+/*
+ * 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.ast;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.cassandra.schema.TableMetadata;
+
+public class TableReference implements Element
+{
+    public final Optional<String> keyspace;
+    public final String name;
+
+    public TableReference(String name)
+    {
+        this(Optional.empty(), name);
+    }
+
+    public TableReference(Optional<String> keyspace, String name)
+    {
+        this.keyspace = Objects.requireNonNull(keyspace);
+        this.name = Objects.requireNonNull(name);
+    }
+
+    public static TableReference from(TableMetadata metadata)
+    {
+        return new TableReference(Optional.of(metadata.keyspace), metadata.name);
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        if (keyspace.isPresent())
+        {
+            Symbol.maybeQuote(sb, keyspace.get());
+            sb.append('.');
+        }
+        Symbol.maybeQuote(sb, name);
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Txn.java b/test/unit/org/apache/cassandra/cql3/ast/Txn.java
new file mode 100644
index 0000000000..2e60574eac
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Txn.java
@@ -0,0 +1,399 @@
+/*
+ * 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.ast;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.cassandra.cql3.FieldIdentifier;
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.marshal.BytesType;
+import org.apache.cassandra.db.marshal.UserType;
+
+public class Txn implements Statement
+{
+    // lets
+    public final List<Let> lets;
+    // return
+    public final Optional<Select> output;
+    public final Optional<If> ifBlock;
+    public final List<Mutation> mutations;
+
+    public Txn(List<Let> lets, Optional<Select> output, Optional<If> ifBlock, List<Mutation> mutations)
+    {
+        this.lets = lets;
+        this.output = output;
+        this.ifBlock = ifBlock;
+        if (ifBlock.isPresent() && !mutations.isEmpty())
+            throw new IllegalArgumentException("Unable to define both IF and non-conditional mutations");
+        this.mutations = mutations;
+    }
+
+    public static Txn wrap(Select select)
+    {
+        return new Txn(Collections.emptyList(), Optional.of(select), Optional.empty(), Collections.emptyList());
+    }
+
+    public static Txn wrap(Mutation mutation)
+    {
+        return new Txn(Collections.emptyList(), Optional.empty(), Optional.empty(), Collections.singletonList(mutation));
+    }
+
+    public static Builder builder()
+    {
+        return new Builder();
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        sb.append("BEGIN TRANSACTION");
+
+        formatter.group(sb);
+        stream().forEach(e -> {
+            formatter.section(sb);
+            e.toCQL(sb, formatter);
+            if (!(e instanceof If))
+                sb.append(';');
+        });
+        formatter.endgroup(sb);
+        formatter.section(sb);
+        sb.append("COMMIT TRANSACTION");
+    }
+
+    @Override
+    public Stream<? extends Element> stream()
+    {
+        Stream<? extends Element> ret = lets.stream();
+        if (output.isPresent())
+            ret = Stream.concat(ret, Stream.of(output.get()));
+        if (ifBlock.isPresent())
+            ret = Stream.concat(ret, Stream.of(ifBlock.get()));
+        ret = Stream.concat(ret, mutations.stream());
+        return ret;
+    }
+
+    @Override
+    public String toString()
+    {
+        return toCQL();
+    }
+
+    @Override
+    public Kind kind()
+    {
+        return Kind.TXN;
+    }
+
+    @Override
+    public Statement visit(Visitor v)
+    {
+        var u = v.visit(this);
+        if (u != this) return u;
+        boolean updated = false;
+        List<Let> lets = new ArrayList<>(this.lets.size());
+        for (Let l : this.lets)
+        {
+            Statement update = l.select.visit(v);
+            if (!(update instanceof Select))
+                throw new IllegalArgumentException("Unable to use type " + update.getClass() + " in Let");
+            boolean localUpdated = update != l.select;
+            updated |= localUpdated;
+            lets.add(!localUpdated ? l : new Let(l.symbol, (Select) update));
+        }
+        Optional<Select> output;
+        if (this.output.isPresent())
+        {
+            Select select = this.output.get();
+            Statement update = select.visit(v);
+            if (!(update instanceof Select))
+                throw new IllegalArgumentException("Unable to use type " + update.getClass() + " in output");
+            boolean localUpdated = select != update;
+            updated |= localUpdated;
+            output = !localUpdated ? this.output : Optional.ofNullable((Select) update);
+        }
+        else
+        {
+            output = this.output;
+        }
+        Optional<If> ifBlock;
+        if (this.ifBlock.isPresent())
+        {
+            If block = this.ifBlock.get();
+            boolean localUpdated = false;
+            Conditional c = block.conditional.visit(v);
+            localUpdated |= c != block.conditional;
+            List<Mutation> mutations = new ArrayList<>(this.mutations.size());
+            for (Mutation m : block.mutations)
+            {
+                Statement update = m.visit(v);
+                if (!(update instanceof Mutation))
+                    throw new IllegalArgumentException("Unable to use type " + update.getClass() + " where Mutation is expected");
+                updated |= update != m;
+                mutations.add((Mutation) update);
+            }
+            ifBlock = !localUpdated ? this.ifBlock : Optional.of(new If(c, mutations));
+        }
+        else
+        {
+            ifBlock = this.ifBlock;
+        }
+        List<Mutation> mutations = new ArrayList<>(this.mutations.size());
+        for (Mutation m : this.mutations)
+        {
+            Statement update = m.visit(v);
+            if (!(update instanceof Mutation))
+                throw new IllegalArgumentException("Unable to use type " + update.getClass() + " where Mutation is expected");
+            updated |= update != m;
+            mutations.add((Mutation) update);
+        }
+        if (!updated) return this;
+        return new Txn(lets, output, ifBlock, mutations);
+    }
+
+    private static void recursiveReferences(Collection<Reference> accum, Reference ref)
+    {
+        //NOTE: this doesn't actually collect the references due to lack of support in CQL within a Txn, need to fix those cases
+        //Coverage: Map/Set/List are a bit annoying as they use real values and not something known in the schema, so may need to handle that different in validation
+        AbstractType<?> type = ref.type().unwrap();
+        if (type.isCollection())
+        {
+            //TODO Caleb to add support for [] like normal read/write supports
+//            if (type instanceof SetType)
+//            {
+//                // [value] syntax
+//                SetType set = (SetType) type;
+//                AbstractType subType = set.getElementsType();
+
+//            }
+//            else if (type instanceof MapType)
+//            {
+//                // [key] syntax
+//                MapType map = (MapType) type;
+//                AbstractType keyType = map.getKeysType();
+//                AbstractType valueType = map.getValuesType();
+//            }
+            // see Selectable.specForElementOrSlice; ListType is not supported
+        }
+        else if (type.isUDT())
+        {
+            //TODO Caleb to support multiple nesting
+//            UserType udt = (UserType) type;
+//            for (int i = 0; i < udt.size(); i++)
+//            {
+//                Reference subRef = ref.add(udt.fieldName(i).toString(), udt.type(i));
+//                accum.add(subRef);
+//                recursiveReferences(accum, subRef);
+//            }
+        }
+    }
+
+    public static class Builder
+    {
+        private final Map<String, Select> lets = new LinkedHashMap<>();
+        // no type system so don't need easy lookup to Expression; just existence check
+        private final Set<Reference> allowedReferences = new HashSet<>();
+        private Optional<Select> output = Optional.empty();
+        private Optional<If> ifBlock = Optional.empty();
+        private final List<Mutation> mutations = new ArrayList<>();
+
+        public Set<Reference> allowedReferences()
+        {
+            return Collections.unmodifiableSet(allowedReferences);
+        }
+
+        public Map<String, Select> lets()
+        {
+            return Collections.unmodifiableMap(lets);
+        }
+
+        public boolean isEmpty()
+        {
+            // don't include output as 'BEGIN TRANSACTION SELECT "000000000000000010000"; COMMIT TRANSACTION' isn't valid
+//            return lets.isEmpty();
+            // TransactionStatement defines empty as no SELECT or updates
+            return !output.isPresent() && !ifBlock.isPresent() && mutations.isEmpty();
+        }
+
+        public Builder addLet(String name, Select.Builder select)
+        {
+            return addLet(name, select.build());
+        }
+
+        public Builder addLet(String name, Select select)
+        {
+            if (lets.containsKey(name))
+                throw new IllegalArgumentException("Let name " + name + " already exists");
+            lets.put(name, select);
+
+            Reference ref = Reference.of(new Symbol.UnquotedSymbol(name, toNamedTuple(select)));
+            for (Expression e : select.selections)
+                addAllowedReference(ref.add(e));
+            return this;
+        }
+
+        private AbstractType<?> toNamedTuple(Select select)
+        {
+            //TODO don't rely on UserType...
+            List<FieldIdentifier> fieldNames = new ArrayList<>(select.selections.size());
+            List<AbstractType<?>> fieldTypes = new ArrayList<>(select.selections.size());
+            for (Expression e : select.selections)
+            {
+                fieldNames.add(FieldIdentifier.forQuoted(e.name()));
+                fieldTypes.add(e.type());
+            }
+            return new UserType(null, null, fieldNames, fieldTypes, false);
+        }
+
+        private Builder addAllowedReference(Reference ref)
+        {
+            allowedReferences.add(ref);
+            recursiveReferences(allowedReferences, ref);
+            return this;
+        }
+
+        public Builder addReturn(Select select)
+        {
+            output = Optional.of(select);
+            return this;
+        }
+
+        public Builder addReturnReferences(String... names)
+        {
+            Select.Builder builder = new Select.Builder();
+            for (String name : names)
+                builder.selection(ref(name));
+            addReturn(builder.build());
+            return this;
+        }
+
+        private Reference ref(String name)
+        {
+            if (!name.contains("."))
+                return Reference.of(new Symbol(name, BytesType.instance));
+            String[] splits = name.split("\\.");
+            Reference.Builder builder = new Reference.Builder();
+            builder.add(splits);
+            return builder.build();
+        }
+
+        public Builder addIf(If block)
+        {
+            ifBlock = Optional.of(block);
+            return this;
+        }
+
+        public Builder addIf(Conditional conditional, Mutation... mutations)
+        {
+            return addIf(conditional, Arrays.asList(mutations));
+        }
+
+        public Builder addIf(Conditional conditional, List<Mutation> mutations)
+        {
+            return addIf(new If(conditional, mutations));
+        }
+
+        public Builder addUpdate(Mutation mutation)
+        {
+            this.mutations.add(Objects.requireNonNull(mutation));
+            return this;
+        }
+
+        public Txn build()
+        {
+            List<Let> lets = this.lets.entrySet().stream().map(e -> new Let(e.getKey(), e.getValue())).collect(Collectors.toList());
+            return new Txn(lets, output, ifBlock, new ArrayList<>(mutations));
+        }
+    }
+
+    public static class If implements Element
+    {
+        private final Conditional conditional;
+        private final List<Mutation> mutations;
+
+        public If(Conditional conditional, List<Mutation> mutations)
+        {
+            this.conditional = conditional;
+            this.mutations = mutations;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            sb.append("IF ");
+            conditional.toCQL(sb, formatter);
+            sb.append(" THEN");
+            formatter.group(sb);
+            for (Mutation mutation : mutations)
+            {
+                formatter.element(sb);
+                mutation.toCQL(sb, formatter);
+                sb.append(';');
+            }
+            formatter.endgroup(sb);
+            formatter.section(sb);
+            sb.append("END IF");
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return Stream.concat(Stream.of(conditional), mutations.stream());
+        }
+    }
+
+    public static class Let implements Element
+    {
+        public final String symbol;
+        public final Select select;
+
+        public Let(String symbol, Select select)
+        {
+            this.symbol = symbol;
+            this.select = select;
+        }
+
+        @Override
+        public void toCQL(StringBuilder sb, CQLFormatter formatter)
+        {
+            sb.append("LET ").append(symbol).append(" = (");
+            formatter.subgroup(sb);
+            select.toCQL(sb, formatter);
+            formatter.endsubgroup(sb);
+            sb.append(")");
+        }
+
+        @Override
+        public Stream<? extends Element> stream()
+        {
+            return Stream.of(select);
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/TypeHint.java b/test/unit/org/apache/cassandra/cql3/ast/TypeHint.java
new file mode 100644
index 0000000000..d49f30c118
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/TypeHint.java
@@ -0,0 +1,84 @@
+/*
+ * 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.ast;
+
+import java.util.stream.Stream;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+
+public class TypeHint implements Expression
+{
+    public final Expression e;
+    public final AbstractType<?> type;
+
+    public TypeHint(Expression e, AbstractType<?> type)
+    {
+        this.e = e;
+        this.type = type;
+    }
+
+    public TypeHint(Expression e)
+    {
+        this.e = e;
+        this.type = e.type();
+    }
+
+    /**
+     * {@code ? + ?} is not clear to parsing, so rather than assume the type (like we do for literals) we fail and ask
+     * the user to CAST... so need to {@link TypeHint} when a {@link Bind} is found.
+     *
+     * Wait, {@link TypeHint} and not {@link Cast}?  See CASSANDRA-17915...
+     */
+    public static Expression maybeApplyTypeHint(Expression e)
+    {
+        if (!(e instanceof Bind))
+            return e;
+        // see https://the-asf.slack.com/archives/CK23JSY2K/p1663788235000449
+        return new TypeHint(e);
+    }
+
+    @Override
+    public void toCQL(StringBuilder sb, CQLFormatter formatter)
+    {
+        sb.append('(').append(type.asCQL3Type()).append(") ");
+        e.toCQL(sb, formatter);
+    }
+
+    @Override
+    public AbstractType<?> type()
+    {
+        return type;
+    }
+
+    @Override
+    public Stream<? extends Element> stream()
+    {
+        return Stream.of(e);
+    }
+
+    @Override
+    public Expression visit(Visitor v)
+    {
+        var u = v.visit(this);
+        if (u != this) return u;
+        var e = this.e.visit(v);
+        if (e == this.e) return this;
+        return new TypeHint(e, type);
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Value.java b/test/unit/org/apache/cassandra/cql3/ast/Value.java
new file mode 100644
index 0000000000..f04b5fcd36
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Value.java
@@ -0,0 +1,36 @@
+/*
+ * 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.ast;
+
+import java.nio.ByteBuffer;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+
+public interface Value extends Expression
+{
+    Object value();
+    ByteBuffer valueEncoded();
+    Value with(Object value, AbstractType<?> type);
+
+    @Override
+    default Value visit(Visitor v)
+    {
+        return v.visit(this);
+    }
+}
diff --git a/test/unit/org/apache/cassandra/cql3/ast/Visitor.java b/test/unit/org/apache/cassandra/cql3/ast/Visitor.java
new file mode 100644
index 0000000000..71e4797c25
--- /dev/null
+++ b/test/unit/org/apache/cassandra/cql3/ast/Visitor.java
@@ -0,0 +1,159 @@
+/*
+ * 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.ast;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+
+import accord.utils.Invariants;
+
+/**
+ * Visits and conditionally replaces an element in the ast.  Each "visit" method offers the ability to be notified when the
+ * desired type is found in the ast tree, and allows the visitor to replace the current element (return the element unchanged
+ * if no mutations are desired, return a different element if a change is desired).
+ *
+ * For elements that support visitors, the following pattern should be respected.
+ * <ol>
+ *     <li>check the top level element first
+ *         {code}
+ *         var u = visitor.visit(this);
+ *         if (u != this) return u;
+ *     {code}</li>
+ *     <li>check each sub element; replacing the top level element if any sub-elements are changed
+ *     {code}
+ *     boolean updated = false;
+ *     var a = a.visit(visitor);
+ *     updated |= (a != this.a);
+ *     ...
+ *     return !updated ? this : copy...
+ *     {code}
+ *     </li>
+ * </ol>
+ */
+public interface Visitor
+{
+    default Statement visit(Statement s)
+    {
+        return s;
+    }
+
+    default Expression visit(Expression e)
+    {
+        if (e instanceof Value) return visit((Value) e);
+        if (e instanceof ReferenceExpression) return visit((ReferenceExpression) e);
+        return e;
+    }
+
+    default Conditional visit(Conditional c) { return c; }
+
+    default ReferenceExpression visit(ReferenceExpression r)
+    {
+        return r;
+    }
+
+    default Value visit(Value v) { return v; }
+
+    class CompositeVisitor implements Visitor
+    {
+        private final List<Visitor> visitors;
+
+        private CompositeVisitor(List<Visitor> visitors)
+        {
+            this.visitors = visitors;
+        }
+
+        public static CompositeVisitor of(Visitor... visitors)
+        {
+            return of(Arrays.asList(visitors));
+        }
+
+        public static CompositeVisitor of(List<Visitor> visitors)
+        {
+            Invariants.checkArgument(!visitors.isEmpty(), "Visitors may not be empty");
+
+            if (Stream.of(visitors).noneMatch(v -> v instanceof CompositeVisitor))
+                return new CompositeVisitor(visitors);
+            List<Visitor> flatten = new ArrayList<>();
+            for (Visitor v : visitors)
+            {
+                if (!(v instanceof CompositeVisitor))
+                {
+                    flatten.add(v);
+                    continue;
+                }
+                CompositeVisitor cv = (CompositeVisitor) v;
+                flatten.addAll(cv.visitors);
+            }
+            return new CompositeVisitor(flatten);
+        }
+
+        public CompositeVisitor append(Visitor v)
+        {
+            if (v instanceof CompositeVisitor)
+            {
+                CompositeVisitor other = (CompositeVisitor) v;
+                List<Visitor> vs = new ArrayList<>(visitors.size() + other.visitors.size());
+                vs.addAll(visitors);
+                vs.addAll(other.visitors);
+                return new CompositeVisitor(vs);
+            }
+            else
+            {
+                List<Visitor> vs = new ArrayList<>(visitors.size() + 1);
+                vs.addAll(visitors);
+                vs.add(v);
+                return new CompositeVisitor(vs);
+            }
+        }
+
+        @Override
+        public Expression visit(Expression e)
+        {
+            for (var v : visitors)
+                e = v.visit(e);
+            return e;
+        }
+
+        @Override
+        public Conditional visit(Conditional c)
+        {
+            for (var v : visitors)
+                c = v.visit(c);
+            return c;
+        }
+
+        @Override
+        public Statement visit(Statement s)
+        {
+            for (var v : visitors)
+                s = v.visit(s);
+            return s;
+        }
+
+        @Override
+        public Value visit(Value c)
+        {
+            for (var v : visitors)
+                c = v.visit(c);
+            return c;
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@cassandra.apache.org
For additional commands, e-mail: commits-help@cassandra.apache.org