Repository: jakartaee/data
Pull request #895 (2024-11-05 22:49:53)

dynamic query restrictions
This PR contains some ideas that I was experimenting with locally for dynamic query restrictions #829 and hadn't been planning to propose until after a lot more discussion occurred.  However, because code changes are already being proposed and I wanted to illustrate how this should tie into the static metamodel, I decided to share this prematurely so everyone can see what I was originally aiming for with the pattern.

```
@Repository
public interface Products extends CrudRepository<Product, Long> {
    List<Product> search(String namePattern,
                         Restriction<Product> restriction,
                         Order<Product> order);
}
```

Usage with single restriction,
```
    List<Product> found = products.search(pattern,
                                          _Product.price.lessThan(50.0f),
                                          Order.by(_Product.price.desc(),
                                                   _Product.name.asc()));
```

Usage with multiple restrictions,
```
    List<Product> found = products.search(pattern,
                                          Restrict.all(_Product.price.greaterThan(25.0f),
                                                       _Product.price.lessThan(50.0f),
                                                       _Product.name.startsWith(pattern).ignoreCase(),
                                                       _Product.image.notNull()),
                                          Order.by(_Product.price.desc(),
                                                   _Product.name.asc()));
```

This was just the basics, and doesn't yet include not/negation, or Pattern/Range, which might make parts of it unnecessary.  It also just occurred to me that if we had a TextRestriction subclass, which I did not include in this, we could omit the `*IgnoreCase` methods and have `.ignoreCase()` instead.


Patch:
diff --git a/api/src/main/java/jakarta/data/BasicRestriction.java b/api/src/main/java/jakarta/data/BasicRestriction.java
new file mode 100644
index 00000000..f7c3b167
--- /dev/null
+++ b/api/src/main/java/jakarta/data/BasicRestriction.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+public interface BasicRestriction<T> extends Restriction<T> {
+    Operator comparison();
+
+    String field();
+
+    Object value();
+}
diff --git a/api/src/main/java/jakarta/data/BasicRestrictionRecord.java b/api/src/main/java/jakarta/data/BasicRestrictionRecord.java
new file mode 100644
index 00000000..e58ca18f
--- /dev/null
+++ b/api/src/main/java/jakarta/data/BasicRestrictionRecord.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+// Internal implementation class.
+// The proper way for users to obtain instances is via
+// the static metamodel or Restrict.* methods 
+
+import java.util.Objects;
+
+record BasicRestrictionRecord<T>(
+        String field,
+        boolean isNegated,
+        Operator comparison,
+        Object value) implements BasicRestriction<T> {
+
+    BasicRestrictionRecord {
+        Objects.requireNonNull(field, "Field must not be null");
+    }
+
+    BasicRestrictionRecord(String field, Operator comparison, Object value) {
+        this(field, false, comparison, value);
+    }
+}
diff --git a/api/src/main/java/jakarta/data/CompositeRestriction.java b/api/src/main/java/jakarta/data/CompositeRestriction.java
new file mode 100644
index 00000000..1eeffbf9
--- /dev/null
+++ b/api/src/main/java/jakarta/data/CompositeRestriction.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+import java.util.List;
+
+public interface CompositeRestriction<T> extends Restriction<T> {
+    List<Restriction<T>> restrictions();
+
+    Type type();
+
+    enum Type {
+        ALL,
+        ANY
+    }
+}
diff --git a/api/src/main/java/jakarta/data/CompositeRestrictionRecord.java b/api/src/main/java/jakarta/data/CompositeRestrictionRecord.java
new file mode 100644
index 00000000..a8cd5e0f
--- /dev/null
+++ b/api/src/main/java/jakarta/data/CompositeRestrictionRecord.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+import java.util.List;
+
+// Internal implementation class.
+// The proper way for users to obtain instances is via
+// the Restrict.any(...) or Restrict.all(...) methods
+
+record CompositeRestrictionRecord<T>(
+        Type type,
+        List<Restriction<T>> restrictions,
+        boolean isNegated) implements CompositeRestriction<T> {
+
+    CompositeRestrictionRecord {
+        if (restrictions == null || restrictions.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "Cannot create a composite restriction without any restrictions to combine.");
+        }
+    }
+
+    CompositeRestrictionRecord(Type type, List<Restriction<T>> restrictions) {
+        this(type, restrictions, false);
+    }
+}
\ No newline at end of file
diff --git a/api/src/main/java/jakarta/data/Operator.java b/api/src/main/java/jakarta/data/Operator.java
new file mode 100644
index 00000000..a8d448c8
--- /dev/null
+++ b/api/src/main/java/jakarta/data/Operator.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+public enum Operator {
+    EQUAL,
+    GREATER_THAN,
+    GREATER_THAN_EQUAL,
+    IN,
+    LESS_THAN,
+    LESS_THAN_EQUAL,
+    LIKE
+}
diff --git a/api/src/main/java/jakarta/data/Restrict.java b/api/src/main/java/jakarta/data/Restrict.java
new file mode 100644
index 00000000..96b24d8e
--- /dev/null
+++ b/api/src/main/java/jakarta/data/Restrict.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+import java.util.List;
+import java.util.Set;
+
+// TODO document
+// This is one of two places from which to obtain restrictions.
+// The other place is from static metamodel attributes.
+public class Restrict {
+
+    private static final char CHAR_WILDCARD = '_';
+
+    private static final char ESCAPE_CHAR = '\\';
+
+    // used internally for more readable code
+    private static final boolean ESCAPED = true;
+    private static final boolean NOT = true;
+
+    private static final char STRING_WILDCARD = '%';
+
+    // prevent instantiation
+    private Restrict() {
+    }
+
+    @SafeVarargs
+    public static <T> Restriction<T> all(Restriction<T>... restrictions) {
+        return new CompositeRestrictionRecord<>(CompositeRestriction.Type.ALL,
+                                                List.of(restrictions));
+    }
+
+    @SafeVarargs
+    public static <T> Restriction<T> any(Restriction<T>... restrictions) {
+        return new CompositeRestrictionRecord<>(CompositeRestriction.Type.ANY,
+                                                List.of(restrictions));
+    }
+
+    public static <T, V extends Comparable<V>> Restriction<T> between(V min,
+                                                                      V max,
+                                                                      String field) {
+        return all(greaterThanEqual(min, field),
+                   lessThanEqual(max, field));
+    }
+
+    // TODO Need to think more about how to best cover negation of multiple
+    // and then make negation of Single consistent with it
+
+    public static <T> TextRestriction<T> contains(String substring, String field) {
+        String pattern = toLikeEscaped(CHAR_WILDCARD, STRING_WILDCARD, true, substring, true);
+        return new TextRestrictionRecord<>(field, Operator.LIKE, ESCAPED, pattern);
+    }
+
+    public static <T> TextRestriction<T> endsWith(String suffix, String field) {
+        String pattern = toLikeEscaped(CHAR_WILDCARD, STRING_WILDCARD, true, suffix, false);
+        return new TextRestrictionRecord<>(field, Operator.LIKE, ESCAPED, pattern);
+    }
+
+    public static <T> Restriction<T> equalTo(Object value, String field) {
+        return new BasicRestrictionRecord<>(field, Operator.EQUAL, value);
+    }
+
+    public static <T> TextRestriction<T> equalTo(String value, String field) {
+        return new TextRestrictionRecord<>(field, Operator.EQUAL, value);
+    }
+
+    public static <T, V extends Comparable<V>> Restriction<T> greaterThan(V value, String field) {
+        return new BasicRestrictionRecord<>(field, Operator.GREATER_THAN, value);
+    }
+
+    public static <T> TextRestriction<T> greaterThan(String value, String field) {
+        return new TextRestrictionRecord<>(field, Operator.GREATER_THAN, value);
+    }
+
+    public static <T, V extends Comparable<V>> Restriction<T> greaterThanEqual(V value, String field) {
+        return new BasicRestrictionRecord<>(field, Operator.GREATER_THAN_EQUAL, value);
+    }
+
+    public static <T> TextRestriction<T> greaterThanEqual(String value, String field) {
+        return new TextRestrictionRecord<>(field, Operator.GREATER_THAN_EQUAL, value);
+    }
+
+    public static <T> Restriction<T> in(Set<Object> values, String field) {
+        return new BasicRestrictionRecord<>(field, Operator.IN, values);
+    }
+
+    public static <T, V extends Comparable<V>> Restriction<T> lessThan(V value, String field) {
+        return new BasicRestrictionRecord<>(field, Operator.LESS_THAN, value);
+    }
+
+    public static <T> TextRestriction<T> lessThan(String value, String field) {
+        return new TextRestrictionRecord<>(field, Operator.LESS_THAN, value);
+    }
+
+    public static <T, V extends Comparable<V>> Restriction<T> lessThanEqual(V value, String field) {
+        return new BasicRestrictionRecord<>(field, Operator.LESS_THAN_EQUAL, value);
+    }
+
+    public static <T> TextRestriction<T> lessThanEqual(String value, String field) {
+        return new TextRestrictionRecord<>(field, Operator.LESS_THAN_EQUAL, value);
+    }
+
+    // TODO this would be possible if Pattern is added, but is it even useful?
+    //public static <T> TextRestriction<T> like(Pattern pattern, String field) {
+    //    return new TextRestriction<>(field, Operator.LIKE, ESCAPED, pattern);
+    //}
+
+    public static <T> TextRestriction<T> like(String pattern, String field) {
+        return new TextRestrictionRecord<>(field, Operator.LIKE, pattern);
+    }
+
+    public static <T> TextRestriction<T> like(String pattern,
+                                               char charWildcard,
+                                               char stringWildcard,
+                                               String field) {
+        String p = toLikeEscaped(charWildcard, stringWildcard, false, pattern, false);
+        return new TextRestrictionRecord<>(field, Operator.LIKE, ESCAPED, p);
+    }
+
+    public static <T> Restriction<T> notEqualTo(Object value, String field) {
+        return new BasicRestrictionRecord<>(field, NOT, Operator.EQUAL, value);
+    }
+
+    public static <T> TextRestriction<T> notEqualTo(String value, String field) {
+        return new TextRestrictionRecord<>(field, NOT, Operator.EQUAL, value);
+    }
+
+    public static <T> TextRestriction<T> notContains(String substring, String field) {
+        String pattern = toLikeEscaped(CHAR_WILDCARD, STRING_WILDCARD, true, substring, true);
+        return new TextRestrictionRecord<>(field, NOT, Operator.LIKE, ESCAPED, pattern);
+    }
+
+    public static <T> TextRestriction<T> notEndsWith(String suffix, String field) {
+        String pattern = toLikeEscaped(CHAR_WILDCARD, STRING_WILDCARD, true, suffix, false);
+        return new TextRestrictionRecord<>(field, NOT, Operator.LIKE, ESCAPED, pattern);
+    }
+
+    public static <T> Restriction<T> notIn(Set<Object> values, String field) {
+        return new BasicRestrictionRecord<>(field, NOT, Operator.IN, values);
+    }
+
+    public static <T> TextRestriction<T> notLike(String pattern, String field) {
+        return new TextRestrictionRecord<>(field, NOT, Operator.LIKE, pattern);
+    }
+
+    public static <T> TextRestriction<T> notLike(String pattern,
+                                                  char charWildcard,
+                                                  char stringWildcard,
+                                                  String field) {
+        String p = toLikeEscaped(charWildcard, stringWildcard, false, pattern, false);
+        return new TextRestrictionRecord<>(field, NOT, Operator.LIKE, ESCAPED, p);
+    }
+
+    public static <T> TextRestriction<T> notStartsWith(String prefix, String field) {
+        String pattern = toLikeEscaped(CHAR_WILDCARD, STRING_WILDCARD, false, prefix, true);
+        return new TextRestrictionRecord<>(field, NOT, Operator.LIKE, ESCAPED, pattern);
+    }
+
+    public static <T> TextRestriction<T> startsWith(String prefix, String field) {
+        String pattern = toLikeEscaped(CHAR_WILDCARD, STRING_WILDCARD, false, prefix, true);
+        return new TextRestrictionRecord<>(field, Operator.LIKE, ESCAPED, pattern);
+    }
+
+    /**
+     * Converts the literal pattern into an escaped LIKE pattern.
+     * This method prepends a % character if previous characters are allowed,
+     * escapes the charWildcard (typically _), the stringWildcard (typically %),
+     * and the \ character within the literal by inserting \ prior to each,
+     * and then appends a % character if subsequent characters are allowed.
+     *
+     * @param charWildcard    single character wildcard, typically _.
+     * @param stringWildcard  0 or more character wildcard, typically %.
+     * @param allowPrevious   whether to allow characters prior to the text.
+     * @param literal text    that is not escaped that must be matched.
+     * @param allowSubsequent whether to allow more characters after the text.
+     * @return escaped pattern.
+     * @throws IllegalArgumentException if the same character is supplied for
+     *                                  both wildcard types.
+     */
+    // TODO could move to Pattern class
+    private static String toLikeEscaped(char charWildcard,
+                                        char stringWildcard,
+                                        boolean allowPrevious,
+                                        String literal,
+                                        boolean allowSubsequent) {
+        if (charWildcard == stringWildcard)
+            throw new IllegalArgumentException(
+                    "Cannot use the same character (" + charWildcard +
+                    ") for both types of wildcards.");
+
+        int length = literal.length();
+        StringBuilder s = new StringBuilder(length + 10);
+        if (allowPrevious) {
+            s.append(STRING_WILDCARD);
+        }
+        for (int i = 0; i < length; i++) {
+            char ch = literal.charAt(i);
+            if (ch == charWildcard) {
+                s.append(ESCAPE_CHAR)
+                 .append(CHAR_WILDCARD);
+            } else if (ch == stringWildcard) {
+                s.append(ESCAPE_CHAR)
+                 .append(STRING_WILDCARD);
+            } else if (ch == ESCAPE_CHAR) {
+                s.append(ESCAPE_CHAR)
+                 .append(ESCAPE_CHAR);
+            } else {
+                s.append(ch);
+            }
+        }
+        if (allowSubsequent) {
+            s.append(STRING_WILDCARD);
+        }
+        return s.toString();
+    }
+}
diff --git a/api/src/main/java/jakarta/data/Restriction.java b/api/src/main/java/jakarta/data/Restriction.java
new file mode 100644
index 00000000..0548fb4f
--- /dev/null
+++ b/api/src/main/java/jakarta/data/Restriction.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+public interface Restriction<T> {
+    boolean isNegated();
+}
diff --git a/api/src/main/java/jakarta/data/TextRestriction.java b/api/src/main/java/jakarta/data/TextRestriction.java
new file mode 100644
index 00000000..b6c9850c
--- /dev/null
+++ b/api/src/main/java/jakarta/data/TextRestriction.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+public interface TextRestriction<T> extends BasicRestriction<T> {
+    Restriction<T> ignoreCase();
+
+    // TODO can mention in the JavaDoc that a value of true will be ignored
+    // if the database is not not capable of case sensitive comparisons
+    boolean isCaseSensitive();
+
+    boolean isEscaped();
+
+    @Override
+    String value();
+}
diff --git a/api/src/main/java/jakarta/data/TextRestrictionRecord.java b/api/src/main/java/jakarta/data/TextRestrictionRecord.java
new file mode 100644
index 00000000..831fec41
--- /dev/null
+++ b/api/src/main/java/jakarta/data/TextRestrictionRecord.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+// Internal implementation class.
+// The proper way for users to obtain instances is via
+// the static metamodel or Restrict.* methods 
+
+import java.util.Objects;
+
+record TextRestrictionRecord<T>(
+        String field,
+        boolean isNegated,
+        Operator comparison,
+        boolean isCaseSensitive,
+        boolean isEscaped,
+        String value) implements TextRestriction<T> {
+
+    TextRestrictionRecord {
+        Objects.requireNonNull(field, "Field must not be null");
+    }
+
+    TextRestrictionRecord(String field, boolean negated, Operator comparison, boolean escaped, String value) {
+        this(field, negated, comparison, true, escaped, value);
+    }
+
+    TextRestrictionRecord(String field, boolean negated, Operator comparison, String value) {
+        this(field, negated, comparison, true, false, value);
+    }
+
+    TextRestrictionRecord(String field, Operator comparison, boolean escaped, String value) {
+        this(field, false, comparison, true, escaped, value);
+    }
+
+    TextRestrictionRecord(String field, Operator comparison, String value) {
+        this(field, false, comparison, true, false, value);
+    }
+
+    @Override
+    public Restriction<T> ignoreCase() {
+        return new TextRestrictionRecord<>(field, isNegated, comparison, false, isEscaped, value);
+    }
+}
diff --git a/api/src/main/java/jakarta/data/metamodel/Attribute.java b/api/src/main/java/jakarta/data/metamodel/Attribute.java
index 2906554d..147b73ba 100644
--- a/api/src/main/java/jakarta/data/metamodel/Attribute.java
+++ b/api/src/main/java/jakarta/data/metamodel/Attribute.java
@@ -17,7 +17,10 @@
  */
 package jakarta.data.metamodel;
 
-import jakarta.data.Sort;
+import java.util.Set;
+
+import jakarta.data.Restrict;
+import jakarta.data.Restriction;
 
 /**
  * Represents an entity attribute in the {@link StaticMetamodel}.
@@ -25,6 +28,22 @@ import jakarta.data.Sort;
  * @param <T> entity class of the static metamodel.
  */
 public interface Attribute<T> {
+
+    default Restriction<T> equalTo(Object value) {
+        return Restrict.equalTo(value, name());
+    }
+
+    default Restriction<T> in(Object... values) {
+        if (values == null || values.length == 0)
+            throw new IllegalArgumentException("values are required");
+
+        return Restrict.in(Set.of(values), name());
+    }
+
+    default Restriction<T> isNull() {
+        return Restrict.equalTo(null, name());
+    }
+
     /**
      * Obtain the entity attribute name, suitable for use wherever the specification requires
      * an entity attribute name. For example, as the parameter to {@link Sort#asc(String)}.
@@ -32,4 +51,19 @@ public interface Attribute<T> {
      * @return the entity attribute name.
      */
     String name();
+
+    default Restriction<T> notEqualTo(Object value) {
+        return Restrict.notEqualTo(value, name());
+    }
+
+    default Restriction<T> notIn(Object... values) {
+        if (values == null || values.length == 0)
+            throw new IllegalArgumentException("values are required");
+
+        return Restrict.notIn(Set.of(values), name());
+    }
+
+    default Restriction<T> notNull() {
+        return Restrict.notEqualTo(null, name());
+    }
 }
diff --git a/api/src/main/java/jakarta/data/metamodel/SortableAttribute.java b/api/src/main/java/jakarta/data/metamodel/SortableAttribute.java
index 559960a4..650164de 100644
--- a/api/src/main/java/jakarta/data/metamodel/SortableAttribute.java
+++ b/api/src/main/java/jakarta/data/metamodel/SortableAttribute.java
@@ -17,6 +17,8 @@
  */
 package jakarta.data.metamodel;
 
+import jakarta.data.Restrict;
+import jakarta.data.Restriction;
 import jakarta.data.Sort;
 
 /**
@@ -42,6 +44,10 @@ public interface SortableAttribute<T> extends Attribute<T> {
      */
     Sort<T> asc();
 
+    default <V extends Comparable<V>> Restriction<T> between(V min, V max) {
+        return Restrict.between(min, max, name());
+    }
+
     /**
      * Obtain a request for a descending {@link Sort} based on the entity attribute.
      *
@@ -49,4 +55,19 @@ public interface SortableAttribute<T> extends Attribute<T> {
      */
     Sort<T> desc();
 
+    default <V extends Comparable<V>> Restriction<T> greaterThan(V value) {
+        return Restrict.greaterThan(value, name());
+    }
+
+    default <V extends Comparable<V>> Restriction<T> greaterThanEqual(V value) {
+        return Restrict.greaterThanEqual(value, name());
+    }
+
+    default <V extends Comparable<V>> Restriction<T> lessThan(V value) {
+        return Restrict.lessThan(value, name());
+    }
+
+    default <V extends Comparable<V>> Restriction<T> lessThanEqual(V value) {
+        return Restrict.lessThanEqual(value, name());
+    }
 }
diff --git a/api/src/main/java/jakarta/data/metamodel/TextAttribute.java b/api/src/main/java/jakarta/data/metamodel/TextAttribute.java
index c00390f0..9b026025 100644
--- a/api/src/main/java/jakarta/data/metamodel/TextAttribute.java
+++ b/api/src/main/java/jakarta/data/metamodel/TextAttribute.java
@@ -17,7 +17,9 @@
  */
 package jakarta.data.metamodel;
 
+import jakarta.data.Restrict;
 import jakarta.data.Sort;
+import jakarta.data.TextRestriction;
 
 /**
  * Represents an textual entity attribute in the {@link StaticMetamodel}.
@@ -33,6 +35,10 @@ public interface TextAttribute<T> extends SortableAttribute<T> {
      */
     Sort<T> ascIgnoreCase();
 
+    default TextRestriction<T> contains(String substring) {
+        return Restrict.contains(substring, name());
+    }
+
     /**
      * Obtain a request for a descending, case insensitive {@link Sort} based on the entity attribute.
      *
@@ -40,4 +46,73 @@ public interface TextAttribute<T> extends SortableAttribute<T> {
      */
     Sort<T> descIgnoreCase();
 
+    default TextRestriction<T> endsWith(String suffix) {
+        return Restrict.endsWith(suffix, name());
+    }
+
+    default TextRestriction<T> equalTo(String value) {
+        return Restrict.equalTo(value, name());
+    }
+
+    default TextRestriction<T> greaterThan(String value) {
+        return Restrict.greaterThan(value, name());
+    }
+
+    default TextRestriction<T> greaterThanEqual(String value) {
+        return Restrict.greaterThanEqual(value, name());
+    }
+
+    default TextRestriction<T> lessThan(String value) {
+        return Restrict.lessThan(value, name());
+    }
+
+    default TextRestriction<T> lessThanEqual(String value) {
+        return Restrict.lessThanEqual(value, name());
+    }
+
+    // TODO once we have Pattern:
+    //default TextRestriction<T> like(Pattern pattern) {
+    //    return Restrict.like(pattern, name());
+    //}
+
+    default TextRestriction<T> like(String pattern) {
+        return Restrict.like(pattern, name());
+    }
+
+    default TextRestriction<T> like(String pattern,
+                                    char charWildcard,
+                                    char stringWildcard) {
+        return Restrict.like(pattern, charWildcard, stringWildcard, name());
+    }
+
+    default TextRestriction<T> notContains(String substring) {
+        return Restrict.notContains(substring, name());
+    }
+
+    default TextRestriction<T> notEndsWith(String suffix) {
+        return Restrict.notEndsWith(suffix, name());
+    }
+
+    default TextRestriction<T> notEqualTo(String value) {
+        return Restrict.notEqualTo(value, name());
+    }
+
+    default TextRestriction<T> notLike(String pattern) {
+        return Restrict.notLike(pattern, name());
+    }
+
+    default TextRestriction<T> notLike(String pattern,
+                                       char charWildcard,
+                                       char stringWildcard) {
+        return Restrict.notLike(pattern, charWildcard, stringWildcard, name());
+    }
+
+    default TextRestriction<T> notStartsWith(String prefix) {
+        return Restrict.notStartsWith(prefix, name());
+    }
+
+    default TextRestriction<T> startsWith(String prefix) {
+        return Restrict.startsWith(prefix, name());
+    }
+
 }
diff --git a/pom.xml b/pom.xml
index 6026f12a..b16111e0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -95,14 +95,14 @@
         <junit.version>5.11.3</junit.version>
         <pi-test.version>1.17.1</pi-test.version>
         <pitest-junit5-plugin.version>1.2.1</pitest-junit5-plugin.version>
-        <checkstyle.version>10.20.1</checkstyle.version>
+        <checkstyle.version>10.20.0</checkstyle.version>
         <checkstyle.excludes></checkstyle.excludes>
         <jacoco.maven.version>0.8.12</jacoco.maven.version>
         <shrinkwrap.version>1.2.6</shrinkwrap.version>
         <shrinkwrap.resolver.version>3.3.2</shrinkwrap.resolver.version>
         <sigtest.version>2.3</sigtest.version>
         <sonar.jacoco.reportPath>../target/jacoco.exec</sonar.jacoco.reportPath>
-        <sonar.maven.version>5.0.0.4389</sonar.maven.version>
+        <sonar.maven.version>4.0.0.4121</sonar.maven.version>
     </properties>
 
     <dependencyManagement>
diff --git a/spec/pom.xml b/spec/pom.xml
index 1c9c84da..8af4a5fc 100644
--- a/spec/pom.xml
+++ b/spec/pom.xml
@@ -31,7 +31,7 @@
     <name>Jakarta Data Specification</name>
 
     <properties>
-        <asciidoctor-maven.version>3.1.1</asciidoctor-maven.version>
+        <asciidoctor-maven.version>3.1.0</asciidoctor-maven.version>
         <asciidoctorj-pdf.version>2.3.19</asciidoctorj-pdf.version>
         <asciidoctorj.diagram.version>2.3.1</asciidoctorj.diagram.version>
 
diff --git a/tck-dist/pom.xml b/tck-dist/pom.xml
index fc61e51c..02ae41e6 100644
--- a/tck-dist/pom.xml
+++ b/tck-dist/pom.xml
@@ -47,10 +47,10 @@
   <jakarta.data.api.version>${project.version}</jakarta.data.api.version>
 
   <maven.site.skip>true</maven.site.skip>
-  <asciidoctor.maven.plugin.version>3.1.1</asciidoctor.maven.plugin.version>
+  <asciidoctor.maven.plugin.version>3.1.0</asciidoctor.maven.plugin.version>
   <asciidoctorj.version>3.0.0</asciidoctorj.version>
   <asciidoctorj.pdf.version>2.3.19</asciidoctorj.pdf.version>
-  <jruby.version>9.4.9.0</jruby.version>
+  <jruby.version>9.4.8.0</jruby.version>
  </properties>
 
  <dependencies>


Test patch:
diff --git a/api/src/test/java/jakarta/data/BasicRestrictionRecordTest.java b/api/src/test/java/jakarta/data/BasicRestrictionRecordTest.java
new file mode 100644
index 00000000..c46bfb0d
--- /dev/null
+++ b/api/src/test/java/jakarta/data/BasicRestrictionRecordTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+
+class BasicRestrictionRecordTest {
+
+    @Test
+    void shouldCreateBasicRestrictionWithDefaultNegation() {
+        BasicRestrictionRecord<String> restriction = new BasicRestrictionRecord<>("title", Operator.EQUAL, "Java Guide");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("title");
+            soft.assertThat(restriction.isNegated()).isFalse();
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(restriction.value()).isEqualTo("Java Guide");
+        });
+    }
+
+    @Test
+    void shouldCreateBasicRestrictionWithExplicitNegation() {
+        BasicRestrictionRecord<String> restriction = new BasicRestrictionRecord<>("title", true, Operator.EQUAL, "Java Guide");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("title");
+            soft.assertThat(restriction.isNegated()).isTrue();
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(restriction.value()).isEqualTo("Java Guide");
+        });
+    }
+
+    @Test
+    void shouldCreateBasicRestrictionWithNullValue() {
+        // Create a restriction with a null value
+        BasicRestrictionRecord<String> restriction = new BasicRestrictionRecord<>("title", Operator.EQUAL, null);
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("title");
+            soft.assertThat(restriction.isNegated()).isFalse();
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(restriction.value()).isNull();
+        });
+    }
+
+    @Test
+    void shouldSupportNegatedRestrictionUsingDefaultConstructor() {
+        BasicRestrictionRecord<String> restriction = new BasicRestrictionRecord<>("author", Operator.EQUAL, "Unknown");
+        BasicRestrictionRecord<String> negatedRestriction = new BasicRestrictionRecord<>(restriction.field(), true, restriction.comparison(), restriction.value());
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(negatedRestriction.field()).isEqualTo("author");
+            soft.assertThat(negatedRestriction.isNegated()).isTrue();
+            soft.assertThat(negatedRestriction.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(negatedRestriction.value()).isEqualTo("Unknown");
+        });
+    }
+
+    @Test
+    void shouldThrowExceptionWhenFieldIsNull() {
+        assertThatThrownBy(() -> new BasicRestrictionRecord<>(null, Operator.EQUAL, "testValue"))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessage("Field must not be null");
+    }
+}
diff --git a/api/src/test/java/jakarta/data/CompositeRestrictionRecordTest.java b/api/src/test/java/jakarta/data/CompositeRestrictionRecordTest.java
new file mode 100644
index 00000000..79764f64
--- /dev/null
+++ b/api/src/test/java/jakarta/data/CompositeRestrictionRecordTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+import java.util.List;
+
+
+class CompositeRestrictionRecordTest {
+
+
+    @Test
+    void shouldCreateCompositeRestrictionWithDefaultNegation() {
+        Restriction<String> restriction1 = new BasicRestrictionRecord<>("title", Operator.EQUAL, "Java Guide");
+        Restriction<String> restriction2 = new BasicRestrictionRecord<>("author", Operator.EQUAL, "John Doe");
+
+        CompositeRestrictionRecord<String> composite = new CompositeRestrictionRecord<>(
+                CompositeRestriction.Type.ALL,
+                List.of(restriction1, restriction2)
+        );
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(composite.type()).isEqualTo(CompositeRestriction.Type.ALL);
+            soft.assertThat(composite.restrictions()).containsExactly(restriction1, restriction2);
+            soft.assertThat(composite.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateCompositeRestrictionWithExplicitNegation() {
+        Restriction<String> restriction1 = new BasicRestrictionRecord<>("title", Operator.EQUAL, "Java Guide");
+        Restriction<String> restriction2 = new BasicRestrictionRecord<>("author", Operator.EQUAL, "John Doe");
+
+        CompositeRestrictionRecord<String> composite = new CompositeRestrictionRecord<>(
+                CompositeRestriction.Type.ANY,
+                List.of(restriction1, restriction2),
+                true
+        );
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(composite.type()).isEqualTo(CompositeRestriction.Type.ANY);
+            soft.assertThat(composite.restrictions()).containsExactly(restriction1, restriction2);
+            soft.assertThat(composite.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldFailIfEmptyRestrictions() {
+        assertThatThrownBy(() -> new CompositeRestrictionRecord<>(CompositeRestriction.Type.ALL, List.of()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot create a composite restriction without any restrictions to combine.");
+    }
+
+    @Test
+    void shouldPreserveRestrictionsOrder() {
+        Restriction<String> restriction1 = new BasicRestrictionRecord<>("title", Operator.EQUAL, "Java Guide");
+        Restriction<String> restriction2 = new BasicRestrictionRecord<>("author", Operator.EQUAL, "John Doe");
+
+        CompositeRestrictionRecord<String> composite = new CompositeRestrictionRecord<>(
+                CompositeRestriction.Type.ALL,
+                List.of(restriction1, restriction2)
+        );
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(composite.restrictions().get(0)).isEqualTo(restriction1);
+            soft.assertThat(composite.restrictions().get(1)).isEqualTo(restriction2);
+        });
+    }
+
+    @Test
+    void shouldSupportNegationUsingDefaultConstructor() {
+        // Given multiple restrictions
+        Restriction<String> restriction1 = new BasicRestrictionRecord<>("title", Operator.EQUAL, "Java Guide");
+        Restriction<String> restriction2 = new BasicRestrictionRecord<>("author", Operator.EQUAL, "John Doe");
+
+        // When creating a composite restriction and manually setting negation
+        CompositeRestrictionRecord<String> composite = new CompositeRestrictionRecord<>(
+                CompositeRestriction.Type.ALL,
+                List.of(restriction1, restriction2)
+        );
+        CompositeRestrictionRecord<String> negatedComposite = new CompositeRestrictionRecord<>(
+                composite.type(),
+                composite.restrictions(),
+                true
+        );
+
+        // Then validate the negated composite restriction
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(negatedComposite.type()).isEqualTo(CompositeRestriction.Type.ALL);
+            soft.assertThat(negatedComposite.restrictions()).containsExactly(restriction1, restriction2);
+            soft.assertThat(negatedComposite.isNegated()).isTrue();
+        });
+    }
+}
diff --git a/api/src/test/java/jakarta/data/RestrictTest.java b/api/src/test/java/jakarta/data/RestrictTest.java
new file mode 100644
index 00000000..d2914e08
--- /dev/null
+++ b/api/src/test/java/jakarta/data/RestrictTest.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+class RestrictTest {
+
+
+    @Test
+    void shouldCreateEqualToRestriction() {
+        Restriction<String> restriction = Restrict.equalTo("value", "field");
+
+        assertThat(restriction).isInstanceOf(TextRestrictionRecord.class);
+
+        TextRestrictionRecord<String> basic = (TextRestrictionRecord<String>) restriction;
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(basic.field()).isEqualTo("field");
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(basic.value()).isEqualTo("value");
+            soft.assertThat(basic.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateNotEqualToRestriction() {
+        Restriction<String> restriction = Restrict.notEqualTo("value", "field");
+
+        assertThat(restriction).isInstanceOf(TextRestrictionRecord.class);
+
+        TextRestrictionRecord<String> basic = (TextRestrictionRecord<String>) restriction;
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(basic.field()).isEqualTo("field");
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(basic.value()).isEqualTo("value");
+            soft.assertThat(basic.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldCombineAllRestrictionsWithNegation() {
+        Restriction<String> r1 = Restrict.notEqualTo("value1", "field1");
+        Restriction<String> r2 = Restrict.greaterThan(100, "field2");
+
+        Restriction<String> combined = Restrict.all(r1, r2);
+
+        assertThat(combined).isInstanceOf(CompositeRestrictionRecord.class);
+
+        CompositeRestrictionRecord<String> composite = (CompositeRestrictionRecord<String>) combined;
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(composite.type()).isEqualTo(CompositeRestriction.Type.ALL);
+            soft.assertThat(composite.restrictions()).containsExactly(r1, r2);
+            soft.assertThat(composite.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateContainsRestriction() {
+        TextRestriction<String> restriction = Restrict.contains("substring", "field");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("field");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.value()).isEqualTo("%substring%");
+            soft.assertThat(restriction.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateNegatedContainsRestriction() {
+        TextRestriction<String> restriction = Restrict.notContains("substring", "field");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("field");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.value()).isEqualTo("%substring%");
+            soft.assertThat(restriction.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldCreateStartsWithRestriction() {
+        TextRestriction<String> restriction = Restrict.startsWith("prefix", "field");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("field");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.value()).isEqualTo("prefix%");
+            soft.assertThat(restriction.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateNegatedStartsWithRestriction() {
+        TextRestriction<String> restriction = Restrict.notStartsWith("prefix", "field");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("field");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.value()).isEqualTo("prefix%");
+            soft.assertThat(restriction.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldCreateEndsWithRestriction() {
+        TextRestriction<String> restriction = Restrict.endsWith("suffix", "field");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("field");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.value()).isEqualTo("%suffix");
+            soft.assertThat(restriction.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateNegatedEndsWithRestriction() {
+        TextRestriction<String> restriction = Restrict.notEndsWith("suffix", "field");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("field");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.value()).isEqualTo("%suffix");
+            soft.assertThat(restriction.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldEscapeToLikePatternCorrectly() {
+        String result = Restrict.endsWith("test_value", "fieldName").value();
+
+        assertThat(result).isEqualTo("%test\\_value");
+    }
+
+    @Test
+    void shouldThrowExceptionForInvalidWildcard() {
+        assertThatThrownBy(() -> Restrict.like("pattern_value", '_', '_', "fieldName"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot use the same character (_) for both types of wildcards.");
+    }
+}
diff --git a/api/src/test/java/jakarta/data/TextRestrictionRecordTest.java b/api/src/test/java/jakarta/data/TextRestrictionRecordTest.java
new file mode 100644
index 00000000..5b7dcb2a
--- /dev/null
+++ b/api/src/test/java/jakarta/data/TextRestrictionRecordTest.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data;
+
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+
+class TextRestrictionRecordTest {
+
+    @Test
+    void shouldCreateTextRestrictionWithDefaultValues() {
+        TextRestrictionRecord<String> restriction = new TextRestrictionRecord<>(
+                "title",
+                Operator.LIKE,
+                "%Java%"
+        );
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("title");
+            soft.assertThat(restriction.isNegated()).isFalse();
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.value()).isEqualTo("%Java%");
+            soft.assertThat(restriction.isCaseSensitive()).isTrue();
+            soft.assertThat(restriction.isEscaped()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateTextRestrictionWithExplicitNegation() {
+        TextRestrictionRecord<String> restriction = new TextRestrictionRecord<>(
+                "title",
+                true,
+                Operator.LIKE,
+                "%Java%"
+        );
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("title");
+            soft.assertThat(restriction.isNegated()).isTrue();
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.value()).isEqualTo("%Java%");
+            soft.assertThat(restriction.isCaseSensitive()).isTrue();
+            soft.assertThat(restriction.isEscaped()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldIgnoreCaseForTextRestriction() {
+        TextRestrictionRecord<String> restriction = new TextRestrictionRecord<>(
+                "title",
+                Operator.LIKE,
+                "%Java%"
+        );
+
+        Restriction<String> caseInsensitiveRestriction = restriction.ignoreCase();
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(caseInsensitiveRestriction).isInstanceOf(TextRestrictionRecord.class);
+            TextRestrictionRecord<String> textRestriction = (TextRestrictionRecord<String>) caseInsensitiveRestriction;
+            soft.assertThat(textRestriction.field()).isEqualTo("title");
+            soft.assertThat(textRestriction.isNegated()).isFalse();
+            soft.assertThat(textRestriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(textRestriction.value()).isEqualTo("%Java%");
+            soft.assertThat(textRestriction.isCaseSensitive()).isFalse();
+            soft.assertThat(textRestriction.isEscaped()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateTextRestrictionWithEscapedValue() {
+        TextRestrictionRecord<String> restriction = new TextRestrictionRecord<>(
+                "title",
+                Operator.LIKE,
+                true,
+                "%Java%"
+        );
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("title");
+            soft.assertThat(restriction.isNegated()).isFalse();
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.value()).isEqualTo("%Java%");
+            soft.assertThat(restriction.isCaseSensitive()).isTrue();
+            soft.assertThat(restriction.isEscaped()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldSupportNegationForTextRestriction() {
+        TextRestrictionRecord<String> restriction = new TextRestrictionRecord<>(
+                "author",
+                true,
+                Operator.EQUAL,
+                "John Doe"
+        );
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("author");
+            soft.assertThat(restriction.isNegated()).isTrue();
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(restriction.value()).isEqualTo("John Doe");
+            soft.assertThat(restriction.isCaseSensitive()).isTrue();
+            soft.assertThat(restriction.isEscaped()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldThrowExceptionWhenFieldIsNullInTextRestriction() {
+        assertThatThrownBy(() -> new TextRestrictionRecord<>(null, Operator.LIKE, "testValue"))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessage("Field must not be null");
+    }
+}
diff --git a/api/src/test/java/jakarta/data/metamodel/AttributeTest.java b/api/src/test/java/jakarta/data/metamodel/AttributeTest.java
new file mode 100644
index 00000000..f087674c
--- /dev/null
+++ b/api/src/test/java/jakarta/data/metamodel/AttributeTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data.metamodel;
+
+import jakarta.data.BasicRestriction;
+import jakarta.data.Operator;
+import jakarta.data.Restriction;
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class AttributeTest {
+
+    private final Attribute<String> testAttribute = () -> "testAttribute";
+    @Test
+    void shouldCreateEqualToRestriction() {
+        Restriction<String> restriction = testAttribute.equalTo("testValue");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isEqualTo("testValue");
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(basic.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateNotEqualToRestriction() {
+        Restriction<String> restriction = testAttribute.notEqualTo("testValue");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isEqualTo("testValue");
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(basic.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldCreateInRestriction() {
+        Restriction<String> restriction = testAttribute.in("value1", "value2");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isEqualTo(Set.of("value1", "value2"));
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.IN);
+            soft.assertThat(basic.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldThrowExceptionForEmptyInRestriction() {
+        assertThatThrownBy(() -> testAttribute.in())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("values are required");
+    }
+
+    @Test
+    void shouldCreateNotInRestriction() {
+        Restriction<String> restriction = testAttribute.notIn("value1", "value2");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isEqualTo(Set.of("value1", "value2"));
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.IN);
+            soft.assertThat(basic.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldThrowExceptionForEmptyNotInRestriction() {
+        assertThatThrownBy(() -> testAttribute.notIn())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("values are required");
+    }
+
+    @Test
+    void shouldCreateIsNullRestriction() {
+        Restriction<String> restriction = testAttribute.isNull();
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isNull();
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(basic.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateNotNullRestriction() {
+        Restriction<String> restriction = testAttribute.notNull();
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isNull();
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(basic.isNegated()).isTrue();
+        });
+    }
+}
diff --git a/api/src/test/java/jakarta/data/metamodel/SortableAttributeTest.java b/api/src/test/java/jakarta/data/metamodel/SortableAttributeTest.java
new file mode 100644
index 00000000..697284cd
--- /dev/null
+++ b/api/src/test/java/jakarta/data/metamodel/SortableAttributeTest.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data.metamodel;
+
+import jakarta.data.BasicRestriction;
+import jakarta.data.CompositeRestriction;
+import jakarta.data.Operator;
+import jakarta.data.Restriction;
+import jakarta.data.Sort;
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Test;
+
+
+class SortableAttributeTest {
+
+    //it ignores the implementation of the SortableAttribute interface and uses an anonymous class to test the methods
+    private final SortableAttribute<String> testAttribute = new SortableAttribute<String>() {
+        @Override
+        public Sort<String> asc() {
+           throw new UnsupportedOperationException("It is not the focus of this test");
+        }
+
+        @Override
+        public Sort<String> desc() {
+            throw new UnsupportedOperationException("It is not the focus of this test");
+        }
+
+        @Override
+        public String name() {
+            return "testAttribute";
+        }
+    };
+
+    @Test
+    void shouldCreateGreaterThanRestriction() {
+        Restriction<String> restriction = testAttribute.greaterThan(10);
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isEqualTo(10);
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.GREATER_THAN);
+            soft.assertThat(basic.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateGreaterThanEqualRestriction() {
+        Restriction<String> restriction = testAttribute.greaterThanEqual(10);
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isEqualTo(10);
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.GREATER_THAN_EQUAL);
+            soft.assertThat(basic.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateLessThanRestriction() {
+        Restriction<String> restriction = testAttribute.lessThan(10);
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isEqualTo(10);
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.LESS_THAN);
+            soft.assertThat(basic.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateLessThanOrEqualRestriction() {
+        Restriction<String> restriction = testAttribute.lessThanEqual(10);
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(basic.field()).isEqualTo("testAttribute");
+            soft.assertThat(basic.value()).isEqualTo(10);
+            soft.assertThat(basic.comparison()).isEqualTo(Operator.LESS_THAN_EQUAL);
+            soft.assertThat(basic.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateBetweenRestriction() {
+        Restriction<String> restriction = testAttribute.between(5, 15);
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction).isInstanceOf(CompositeRestriction.class);
+            CompositeRestriction<String> composite = (CompositeRestriction<String>) restriction;
+            soft.assertThat(composite.type()).isEqualTo(CompositeRestriction.Type.ALL);
+            soft.assertThat(composite.restrictions()).hasSize(2);
+
+            Restriction<String> lowerBound = composite.restrictions().get(0);
+            soft.assertThat(lowerBound).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> lower = (BasicRestriction<String>) lowerBound;
+            soft.assertThat(lower.field()).isEqualTo("testAttribute");
+            soft.assertThat(lower.value()).isEqualTo(5);
+            soft.assertThat(lower.comparison()).isEqualTo(Operator.GREATER_THAN_EQUAL);
+            soft.assertThat(lower.isNegated()).isFalse();
+
+            Restriction<String> upperBound = composite.restrictions().get(1);
+            soft.assertThat(upperBound).isInstanceOf(BasicRestriction.class);
+            BasicRestriction<String> upper = (BasicRestriction<String>) upperBound;
+            soft.assertThat(upper.field()).isEqualTo("testAttribute");
+            soft.assertThat(upper.value()).isEqualTo(15);
+            soft.assertThat(upper.comparison()).isEqualTo(Operator.LESS_THAN_EQUAL);
+            soft.assertThat(upper.isNegated()).isFalse();
+        });
+    }
+}
diff --git a/api/src/test/java/jakarta/data/metamodel/TextAttributeTest.java b/api/src/test/java/jakarta/data/metamodel/TextAttributeTest.java
new file mode 100644
index 00000000..b14f15a7
--- /dev/null
+++ b/api/src/test/java/jakarta/data/metamodel/TextAttributeTest.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (c) 2024 Contributors to the Eclipse Foundation
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data.metamodel;
+
+import jakarta.data.Operator;
+import jakarta.data.Sort;
+import jakarta.data.TextRestriction;
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Test;
+
+class TextAttributeTest {
+
+
+    private final TextAttribute<String> testAttribute = new TextAttribute<String>() {
+        @Override
+        public Sort<String> ascIgnoreCase() {
+            throw new UnsupportedOperationException("Not the focus of this test.");
+        }
+
+        @Override
+        public Sort<String> descIgnoreCase() {
+            throw new UnsupportedOperationException("Not the focus of this test.");
+        }
+
+        @Override
+        public Sort<String> asc() {
+            throw new UnsupportedOperationException("Not the focus of this test.");
+        }
+
+        @Override
+        public Sort<String> desc() {
+            throw new UnsupportedOperationException("Not the focus of this test.");
+        }
+
+        @Override
+        public String name() {
+            return "testAttribute";
+        }
+    };
+
+    @Test
+    void shouldCreateContainsRestriction() {
+        TextRestriction<String> restriction = testAttribute.contains("testValue");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("testAttribute");
+            soft.assertThat(restriction.value()).isEqualTo("%testValue%");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateStartsWithRestriction() {
+        TextRestriction<String> restriction = testAttribute.startsWith("testValue");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("testAttribute");
+            soft.assertThat(restriction.value()).isEqualTo("testValue%");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateEndsWithRestriction() {
+        TextRestriction<String> restriction = testAttribute.endsWith("testValue");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("testAttribute");
+            soft.assertThat(restriction.value()).isEqualTo("%testValue");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateLikeRestriction() {
+        TextRestriction<String> restriction = testAttribute.like("%test%");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("testAttribute");
+            soft.assertThat(restriction.value()).isEqualTo("%test%");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.isNegated()).isFalse();
+        });
+    }
+
+    @Test
+    void shouldCreateNotContainsRestriction() {
+        TextRestriction<String> restriction = testAttribute.notContains("testValue");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("testAttribute");
+            soft.assertThat(restriction.value()).isEqualTo("%testValue%");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldCreateNotLikeRestriction() {
+        TextRestriction<String> restriction = testAttribute.notLike("%test%");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("testAttribute");
+            soft.assertThat(restriction.value()).isEqualTo("%test%");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldCreateNotStartsWithRestriction() {
+        TextRestriction<String> restriction = testAttribute.notStartsWith("testValue");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("testAttribute");
+            soft.assertThat(restriction.value()).isEqualTo("testValue%");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.isNegated()).isTrue();
+        });
+    }
+
+    @Test
+    void shouldCreateNotEndsWithRestriction() {
+        TextRestriction<String> restriction = testAttribute.notEndsWith("testValue");
+
+        SoftAssertions.assertSoftly(soft -> {
+            soft.assertThat(restriction.field()).isEqualTo("testAttribute");
+            soft.assertThat(restriction.value()).isEqualTo("%testValue");
+            soft.assertThat(restriction.comparison()).isEqualTo(Operator.LIKE);
+            soft.assertThat(restriction.isNegated()).isTrue();
+        });
+    }
+}