Repository: jakartaee/data
Pull request #971 (2025-02-13 10:35:22)

fix up treatment of null restrictions
This makes the following changes:

1. Introduces `UnaryRestriction` and `UnaryOperator` to represent `is null` and `is not null`.
2. Disallows `null` as a value in `BasicRestriction`, since the meaning of `= null` and `<> null` varies between datastores.


Patch:
diff --git a/api/src/main/java/jakarta/data/metamodel/BasicAttribute.java b/api/src/main/java/jakarta/data/metamodel/BasicAttribute.java
index 58498b3d..7c31ce7a 100644
--- a/api/src/main/java/jakarta/data/metamodel/BasicAttribute.java
+++ b/api/src/main/java/jakarta/data/metamodel/BasicAttribute.java
@@ -36,8 +36,7 @@ public interface BasicAttribute<T,V> extends Attribute<T> {
     }
 
     default Restriction<T> isNull() {
-        //TODO: fix this "= NULL" is not the same thing as "IS NULL" in JDQL
-        return Restrict.equalTo(null, name());
+        return Restrict.isNull(name());
     }
 
     default Restriction<T> notEqualTo(V value) {
@@ -52,8 +51,7 @@ public interface BasicAttribute<T,V> extends Attribute<T> {
     }
 
     default Restriction<T> notNull() {
-        //TODO: fix this "<> NULL" is not the same thing as "IS NOT NULL" in JDQL
-        return Restrict.notEqualTo(null, name());
+        return Restrict.notNull(name());
     }
 
 }
diff --git a/api/src/main/java/jakarta/data/metamodel/TextAttribute.java b/api/src/main/java/jakarta/data/metamodel/TextAttribute.java
index 51c34800..efedc1dc 100644
--- a/api/src/main/java/jakarta/data/metamodel/TextAttribute.java
+++ b/api/src/main/java/jakarta/data/metamodel/TextAttribute.java
@@ -50,22 +50,27 @@ public interface TextAttribute<T> extends ComparableAttribute<T,String> {
         return Restrict.endsWith(suffix, name());
     }
 
+    @Override
     default TextRestriction<T> equalTo(String value) {
         return Restrict.equalTo(value, name());
     }
 
+    @Override
     default TextRestriction<T> greaterThan(String value) {
         return Restrict.greaterThan(value, name());
     }
 
+    @Override
     default TextRestriction<T> greaterThanEqual(String value) {
         return Restrict.greaterThanEqual(value, name());
     }
 
+    @Override
     default TextRestriction<T> lessThan(String value) {
         return Restrict.lessThan(value, name());
     }
 
+    @Override
     default TextRestriction<T> lessThanEqual(String value) {
         return Restrict.lessThanEqual(value, name());
     }
@@ -93,6 +98,7 @@ public interface TextAttribute<T> extends ComparableAttribute<T,String> {
         return Restrict.notEndsWith(suffix, name());
     }
 
+    @Override
     default TextRestriction<T> notEqualTo(String value) {
         return Restrict.notEqualTo(value, name());
     }
diff --git a/api/src/main/java/jakarta/data/metamodel/restrict/BasicRestrictionRecord.java b/api/src/main/java/jakarta/data/metamodel/restrict/BasicRestrictionRecord.java
index 201c1c16..59ed4eda 100644
--- a/api/src/main/java/jakarta/data/metamodel/restrict/BasicRestrictionRecord.java
+++ b/api/src/main/java/jakarta/data/metamodel/restrict/BasicRestrictionRecord.java
@@ -30,6 +30,7 @@ record BasicRestrictionRecord<T>(
 
     BasicRestrictionRecord {
         Objects.requireNonNull(attribute, "Attribute must not be null");
+        Objects.requireNonNull(value, "Value must not be null");
     }
 
     @Override
@@ -49,7 +50,7 @@ record BasicRestrictionRecord<T>(
      */
     @Override
     public String toString() {
-        String valueString = value == null ? "null" : value.toString();
+        String valueString = value.toString();
         StringBuilder builder = new StringBuilder(
                 attribute.length() +
                 comparison.name().length() +
diff --git a/api/src/main/java/jakarta/data/metamodel/restrict/Restrict.java b/api/src/main/java/jakarta/data/metamodel/restrict/Restrict.java
index 26b72038..d1530824 100644
--- a/api/src/main/java/jakarta/data/metamodel/restrict/Restrict.java
+++ b/api/src/main/java/jakarta/data/metamodel/restrict/Restrict.java
@@ -79,6 +79,10 @@ public class Restrict {
         return new TextRestrictionRecord<>(attribute, Operator.EQUAL, value);
     }
 
+    public static <T> UnaryRestriction<T> isNull(String attribute) {
+        return new UnaryRestrictionRecord<>(attribute, UnaryOperator.IS_NULL);
+    }
+
     public static <T, V extends Comparable<V>> Restriction<T> greaterThan(V value, String attribute) {
         return new BasicRestrictionRecord<>(attribute, Operator.GREATER_THAN, value);
     }
@@ -146,6 +150,10 @@ public class Restrict {
         return new TextRestrictionRecord<>(attribute, Operator.NOT_EQUAL, value);
     }
 
+    public static <T> UnaryRestriction<T> notNull(String attribute) {
+        return new UnaryRestrictionRecord<>(attribute, UnaryOperator.IS_NOT_NULL);
+    }
+
     public static <T> TextRestriction<T> notContains(String substring, String attribute) {
         String pattern = toLikeEscaped(CHAR_WILDCARD, STRING_WILDCARD, true, substring, true);
         return new TextRestrictionRecord<>(attribute, Operator.NOT_LIKE, ESCAPED, pattern);
diff --git a/api/src/main/java/jakarta/data/metamodel/restrict/UnaryOperator.java b/api/src/main/java/jakarta/data/metamodel/restrict/UnaryOperator.java
new file mode 100644
index 00000000..a14863b9
--- /dev/null
+++ b/api/src/main/java/jakarta/data/metamodel/restrict/UnaryOperator.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2025 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.restrict;
+
+public enum UnaryOperator {
+    IS_NULL, IS_NOT_NULL;
+
+    UnaryOperator negate() {
+        return this == IS_NULL ? IS_NOT_NULL : IS_NULL;
+    }
+}
diff --git a/api/src/main/java/jakarta/data/metamodel/restrict/UnaryRestriction.java b/api/src/main/java/jakarta/data/metamodel/restrict/UnaryRestriction.java
new file mode 100644
index 00000000..689a83da
--- /dev/null
+++ b/api/src/main/java/jakarta/data/metamodel/restrict/UnaryRestriction.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2025 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.restrict;
+
+public interface UnaryRestriction<T> extends Restriction<T> {
+    String attribute();
+
+    UnaryOperator operator();
+
+    @Override
+    UnaryRestriction<T> negate();
+}
diff --git a/api/src/main/java/jakarta/data/metamodel/restrict/UnaryRestrictionRecord.java b/api/src/main/java/jakarta/data/metamodel/restrict/UnaryRestrictionRecord.java
new file mode 100644
index 00000000..248d5189
--- /dev/null
+++ b/api/src/main/java/jakarta/data/metamodel/restrict/UnaryRestrictionRecord.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2025 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.restrict;
+
+public record UnaryRestrictionRecord<T>(String attribute, UnaryOperator operator)
+        implements UnaryRestriction<T> {
+    @Override
+    public UnaryRestriction<T> negate() {
+        return new UnaryRestrictionRecord<>(attribute, operator.negate());
+    }
+
+    @Override
+    public String toString() {
+        return attribute + " " + operator;
+    }
+}


Test patch:
diff --git a/api/src/test/java/jakarta/data/metamodel/AttributeTest.java b/api/src/test/java/jakarta/data/metamodel/AttributeTest.java
index 304ee982..ca7c240f 100644
--- a/api/src/test/java/jakarta/data/metamodel/AttributeTest.java
+++ b/api/src/test/java/jakarta/data/metamodel/AttributeTest.java
@@ -23,6 +23,8 @@ import org.junit.jupiter.api.Test;
 import jakarta.data.metamodel.restrict.BasicRestriction;
 import jakarta.data.metamodel.restrict.Operator;
 import jakarta.data.metamodel.restrict.Restriction;
+import jakarta.data.metamodel.restrict.UnaryOperator;
+import jakarta.data.metamodel.restrict.UnaryRestriction;
 
 import java.util.Set;
 
@@ -102,11 +104,10 @@ class AttributeTest {
         Restriction<String> restriction = testAttribute.isNull();
 
         SoftAssertions.assertSoftly(soft -> {
-            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
-            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(restriction).isInstanceOf(UnaryRestriction.class);
+            UnaryRestriction<String> basic = (UnaryRestriction<String>) restriction;
             soft.assertThat(basic.attribute()).isEqualTo("testAttribute");
-            soft.assertThat(basic.value()).isNull();
-            soft.assertThat(basic.comparison()).isEqualTo(Operator.EQUAL);
+            soft.assertThat(basic.operator()).isEqualTo(UnaryOperator.IS_NULL);
         });
     }
 
@@ -115,11 +116,10 @@ class AttributeTest {
         Restriction<String> restriction = testAttribute.notNull();
 
         SoftAssertions.assertSoftly(soft -> {
-            soft.assertThat(restriction).isInstanceOf(BasicRestriction.class);
-            BasicRestriction<String> basic = (BasicRestriction<String>) restriction;
+            soft.assertThat(restriction).isInstanceOf(UnaryRestriction.class);
+            UnaryRestriction<String> basic = (UnaryRestriction<String>) restriction;
             soft.assertThat(basic.attribute()).isEqualTo("testAttribute");
-            soft.assertThat(basic.value()).isNull();
-            soft.assertThat(basic.comparison()).isEqualTo(Operator.NOT_EQUAL);
+            soft.assertThat(basic.operator()).isEqualTo(UnaryOperator.IS_NOT_NULL);
         });
     }
 }
diff --git a/api/src/test/java/jakarta/data/metamodel/restrict/BasicRestrictionRecordTest.java b/api/src/test/java/jakarta/data/metamodel/restrict/BasicRestrictionRecordTest.java
index 47b93872..dac4734c 100644
--- a/api/src/test/java/jakarta/data/metamodel/restrict/BasicRestrictionRecordTest.java
+++ b/api/src/test/java/jakarta/data/metamodel/restrict/BasicRestrictionRecordTest.java
@@ -52,18 +52,6 @@ class BasicRestrictionRecordTest {
         });
     }
 
-    @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.attribute()).isEqualTo("title");
-            soft.assertThat(restriction.comparison()).isEqualTo(Operator.EQUAL);
-            soft.assertThat(restriction.value()).isNull();
-        });
-    }
-
     @Test
     void shouldNegateLTERestriction() {
         Restriction<Book> numChaptersLTE10 = Restrict.lessThanEqual(10, "numChapters");
@@ -136,4 +124,11 @@ class BasicRestrictionRecordTest {
                 .isInstanceOf(NullPointerException.class)
                 .hasMessage("Attribute must not be null");
     }
+
+    @Test
+    void shouldThrowExceptionWhenValueIsNull() {
+        assertThatThrownBy(() -> new BasicRestrictionRecord<>("title", Operator.EQUAL, null))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessage("Value must not be null");
+    }
 }