diff --git a/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java index 897b893..5580d7e 100644 --- a/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java +++ b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java @@ -17,12 +17,21 @@ package io.seqera.cloudinfo.api; +import java.util.Objects; + /** * Optional query parameters for the Cloudinfo products endpoint. * *
Each flag is applied server-side; unset flags (the default) leave the * corresponding filter disabled and all products are returned. * + *
Implements value-based {@link #equals(Object)} and {@link #hashCode()} so + * instances can be used as map keys / set members and so consumers can rely on + * a deterministic hash for caching. When new filter fields are added, update + * both methods to include them — {@code ProductsQueryTest} guards this with a + * reflection-based check that fails if any declared field is missing from the + * hash. + * *
Usage example: *
{@code
* ProductsQuery query = ProductsQuery.builder()
@@ -61,6 +70,24 @@ public boolean isNvme() {
return nvme;
}
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof ProductsQuery)) return false;
+ ProductsQuery that = (ProductsQuery) o;
+ return sched == that.sched && nvme == that.nvme;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(sched, nvme);
+ }
+
+ @Override
+ public String toString() {
+ return "ProductsQuery{sched=" + sched + ", nvme=" + nvme + '}';
+ }
+
/**
* Creates a new Builder instance for constructing ProductsQuery objects.
*
diff --git a/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/ProductsQueryTest.groovy b/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/ProductsQueryTest.groovy
new file mode 100644
index 0000000..5e9f41a
--- /dev/null
+++ b/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/ProductsQueryTest.groovy
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2026, Seqera Labs
+ *
+ * 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.
+ *
+ */
+
+package io.seqera.cloudinfo.api
+
+import spock.lang.Specification
+
+import java.lang.reflect.Field
+import java.lang.reflect.Modifier
+
+class ProductsQueryTest extends Specification {
+
+ def 'equals returns true for instances with identical field values'() {
+ expect:
+ ProductsQuery.builder().sched(true).nvme(true).build() ==
+ ProductsQuery.builder().sched(true).nvme(true).build()
+ }
+
+ def 'equals returns false when sched differs'() {
+ expect:
+ ProductsQuery.builder().sched(true).build() !=
+ ProductsQuery.builder().sched(false).build()
+ }
+
+ def 'equals returns false when nvme differs'() {
+ expect:
+ ProductsQuery.builder().nvme(true).build() !=
+ ProductsQuery.builder().nvme(false).build()
+ }
+
+ def 'hashCode is consistent with equals'() {
+ given:
+ def a = ProductsQuery.builder().sched(true).nvme(false).build()
+ def b = ProductsQuery.builder().sched(true).nvme(false).build()
+
+ expect:
+ a == b
+ a.hashCode() == b.hashCode()
+ }
+
+ /**
+ * Safety-net guard: any declared instance field on ProductsQuery must
+ * influence hashCode. If a future field is added to ProductsQuery without
+ * being included in hashCode, this test fails for that field. Avoids
+ * silent caching bugs in consumers that key off ProductsQuery.hashCode.
+ */
+ def 'every declared instance field on ProductsQuery affects hashCode'() {
+ given:
+ def declaredFields = ProductsQuery.getDeclaredFields()
+ .findAll { Field f ->
+ !Modifier.isStatic(f.modifiers) && !f.synthetic
+ }
+
+ expect: 'at least one field exists (sanity)'
+ !declaredFields.isEmpty()
+
+ and: 'each field, when flipped from default, produces a different hashCode'
+ declaredFields.each { Field f ->
+ f.accessible = true
+ def defaults = ProductsQuery.builder().build()
+ def flipped = ProductsQuery.builder().build()
+ // Mutate the flipped instance via reflection to a non-default value of the field's type
+ def flippedValue = nonDefaultValueFor(f.type, f.get(defaults))
+ f.set(flipped, flippedValue)
+ assert defaults.hashCode() != flipped.hashCode(),
+ "Field '${f.name}' on ProductsQuery does not contribute to hashCode. " +
+ "Update ProductsQuery.hashCode (and equals) to include this field."
+ assert defaults != flipped,
+ "Field '${f.name}' on ProductsQuery does not contribute to equals. " +
+ "Update ProductsQuery.equals to include this field."
+ }
+ }
+
+ private static Object nonDefaultValueFor(Class> type, Object currentValue) {
+ if (type == boolean.class || type == Boolean) {
+ return !((Boolean) currentValue)
+ }
+ if (type == int.class || type == Integer) {
+ return (currentValue == null ? 0 : (int) currentValue) + 1
+ }
+ if (type == long.class || type == Long) {
+ return (currentValue == null ? 0L : (long) currentValue) + 1L
+ }
+ if (type == String) {
+ return currentValue == null ? 'X' : currentValue + 'X'
+ }
+ // Extend as ProductsQuery grows. Fail loudly if a new type appears.
+ throw new IllegalStateException(
+ "ProductsQueryTest does not know how to flip a field of type ${type.name}; " +
+ "extend nonDefaultValueFor() or update the test.")
+ }
+}