From 06a427a1828b6d2153e6dc9c823152f0be11a1da Mon Sep 17 00:00:00 2001 From: Ryan Dew Date: Thu, 30 Apr 2026 09:01:27 -0700 Subject: [PATCH] MLE-27881 Add cts.param support to Java Client API --- .github/copilot-instructions.md | 50 +++++++++++++++++ .../marklogic/client/expression/CtsExpr.java | 21 +++++++- .../marklogic/client/impl/BaseTypeImpl.java | 44 ++++++++++++++- .../marklogic/client/impl/CtsExprImpl.java | 21 +++++++- .../client/impl/PlanBuilderSubImpl.java | 1 + .../client/impl/CtsParamExprTest.java | 54 +++++++++++++++++++ 6 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 marklogic-client-api/src/test/java/com/marklogic/client/impl/CtsParamExprTest.java diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..6eef34e45 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +# Project Guidelines + +## Architecture +- `marklogic-client-api`: core Java client library for interacting with the MarkLogic REST API. +- `ml-development-tools`: Gradle plugin and generators for Data Services endpoint proxies/tests. +- `marklogic-client-api-functionaltests`: functional/regression-style tests, split into fragile/fast/slow groups. +- `test-app`: ml-gradle deployment project that provisions test infrastructure in MarkLogic. +- `examples`: supporting code used by tests and usage examples. + +## Build And Test +- Prefer Gradle from the repo root. +- Quick compile verification: `./gradlew clean build -x test` +- Core module tests: `./gradlew marklogic-client-api:test` +- Plugin tests (includes generated tests workflow): `./gradlew ml-development-tools:test` +- Functional tests must run in this order to reduce flakiness: + 1. `./gradlew marklogic-client-api-functionaltests:runFragileTests` + 2. `./gradlew marklogic-client-api-functionaltests:runFastFunctionalTests` + 3. `./gradlew marklogic-client-api-functionaltests:runSlowFunctionalTests` + +## Test Environment +- Java 17+ is required for current releases. +- Most tests require a running MarkLogic instance and deployed test resources. +- Typical setup sequence: + 1. `docker compose up -d --build` + 2. `./gradlew -i mlWaitTillReady` + 3. `./gradlew -i mlDeploy` + 4. Run module tests +- Override local MarkLogic connection settings via `gradle-local.properties` (`mlHost`, `mlPassword`). + +## Quality Controls +- Treat compile warnings as failures: project builds enforce `-Xlint:unchecked`, `-Xlint:deprecation`, and `-Werror`. +- Keep dependency security constraints intact (e.g. forced/excluded dependencies for CVE mitigation in Gradle files). +- When adding or changing first-party Java/Kotlin code, run security scanning steps used by this workspace workflow before finalizing changes. +- Do not relax quality gates (tests/compilation) to make a change pass; fix the underlying issue. + +## Code Generation And Automation +- Data Services proxy generation is automated; use `generateEndpointProxies` instead of hand-writing proxy classes. +- `ml-development-tools` test automation uses `generateTests` and `fixMjsModulesForMarkLogic12` before `test`. +- Generated sources commonly include an "IMPORTANT: Do not edit" header. Regenerate from source declarations instead of editing generated output directly. +- For changes affecting generation logic, validate both generator behavior and generated artifact compilation/tests. + +## Conventions For Changes +- Keep edits scoped to the target module; avoid cross-module churn unless required. +- Prefer existing patterns in nearby code over introducing new abstractions. +- For test-related fixes, document whether behavior changes impact unit tests, functional tests, or deployment setup. + +## Docs To Link (Do Not Duplicate) +- `README.md`: product overview, dependency usage, Java compatibility. +- `CONTRIBUTING.md`: local build/test workflow and MarkLogic test setup. +- `ml-development-tools/src/test/example-project/README.md`: plugin-focused usage/testing notes. diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/expression/CtsExpr.java b/marklogic-client-api/src/main/java/com/marklogic/client/expression/CtsExpr.java index a01b41fe1..8e957a865 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/expression/CtsExpr.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/expression/CtsExpr.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ package com.marklogic.client.expression; @@ -38,6 +38,7 @@ // 2023-10-24 Exception: Manual changes have been made to this to expose the string constructors for cts.point and // cts.polygon. These changes can be removed once optic-defs.json in the xdmp repository is updated to define these // constructors. +// 2026-04-15 Exception: Manual changes have been made to expose cts:param prior to generator support. /** * Builds expressions to call functions in the cts server library for a row @@ -2545,6 +2546,24 @@ public interface CtsExpr { * @return a server expression with the cts:query server data type */ public CtsQueryExpr orQuery(ServerExpression queries, XsStringSeqVal options); +/** + * Returns a parameter placeholder for a cts expression. + * + *

+ * Provides a client interface to the cts:param server function. + * @param name The parameter name. (of xs:string) + * @return a server expression with the xs:anyAtomicType server data type + */ + public ServerExpression param(String name); +/** + * Returns a parameter placeholder for a cts expression. + * + *

+ * Provides a client interface to the cts:param server function. + * @param name The parameter name. (of xs:string) + * @return a server expression with the xs:anyAtomicType server data type + */ + public ServerExpression param(XsStringVal name); /** * Returns the part of speech for a cts:token, if any. * diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/BaseTypeImpl.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/BaseTypeImpl.java index 4c88251a8..86069b8c3 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/BaseTypeImpl.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/BaseTypeImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ package com.marklogic.client.impl; @@ -150,7 +150,7 @@ static class ServerExpressionListImpl extends BaseListImpl implemen } static class ServerExpressionCallImpl extends BaseCallImpl implements ServerExpression { ServerExpressionCallImpl(String fnPrefix, String fnName, Object[] fnArgs) { - super(fnPrefix, fnName, convertList(fnArgs)); + super(fnPrefix, fnName, convertList(validateNoOpticParamInCtsCall(fnPrefix, fnName, fnArgs))); } } @@ -394,6 +394,46 @@ static private void astifyObject(StringBuilder strb, Object value) { } } + static private Object[] validateNoOpticParamInCtsCall(String fnPrefix, String fnName, Object[] fnArgs) { + if (!"cts".equals(fnPrefix) || fnArgs == null) { + return fnArgs; + } + if (containsPlanParam(fnArgs)) { + throw new IllegalArgumentException( + "Cannot pass op:param() to cts:" + fnName + "(). Use cts:param() for cts namespace expressions." + ); + } + return fnArgs; + } + + static private boolean containsPlanParam(Object value) { + if (value == null) { + return false; + } + if (value instanceof PlanParamExpr) { + return true; + } + if (value instanceof Object[]) { + for (Object item : (Object[]) value) { + if (containsPlanParam(item)) { + return true; + } + } + return false; + } + if (value instanceof BaseListImpl) { + return containsPlanParam(((BaseListImpl) value).getArgsImpl()); + } + if (value instanceof BaseMapImpl) { + return containsPlanParam(((BaseMapImpl) value).getMap().values().toArray()); + } + if (value instanceof java.util.Map) { + java.util.Map mapValue = (java.util.Map) value; + return containsPlanParam(mapValue.keySet().toArray()) || containsPlanParam(mapValue.values().toArray()); + } + return false; + } + static BaseArgImpl[] convertList(Object[] items) { return convertList(items, BaseArgImpl.class); } diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/CtsExprImpl.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/CtsExprImpl.java index ac90029ed..4b32c17df 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/CtsExprImpl.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/CtsExprImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ package com.marklogic.client.impl; @@ -41,6 +41,7 @@ // 2023-10-24 Exception: Manual changes have been made to this to expose the string constructors for cts.point and // cts.polygon. These changes can be removed once optic-defs.json in the xdmp repository is updated to define these // constructors. +// 2026-04-15 Exception: Manual changes have been made to expose cts:param prior to generator support. class CtsExprImpl implements CtsExpr { @@ -1684,6 +1685,24 @@ public CtsQueryExpr orQuery(ServerExpression queries, XsStringSeqVal options) { } + @Override + public ServerExpression param(String name) { + if (name == null) { + throw new IllegalArgumentException("name parameter for param() cannot be null"); + } + return param(new XsValueImpl.StringValImpl(name)); + } + + + @Override + public ServerExpression param(XsStringVal name) { + if (name == null) { + throw new IllegalArgumentException("name parameter for param() cannot be null"); + } + return new XsExprImpl.AnyAtomicTypeCallImpl("cts", "param", new Object[]{ name }); + } + + @Override public ServerExpression partOfSpeech(ServerExpression token) { if (token == null) { diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/PlanBuilderSubImpl.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/PlanBuilderSubImpl.java index 2d6580458..a4e23db9e 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/PlanBuilderSubImpl.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/PlanBuilderSubImpl.java @@ -694,6 +694,7 @@ public PlanPrefixer prefixer(String base) { public PlanParamExpr param(String name) { return new PlanParamBase(name); } + @Override public PlanParamExpr param(XsStringVal name) { if (name == null) { diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/impl/CtsParamExprTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/impl/CtsParamExprTest.java new file mode 100644 index 000000000..d29e9c309 --- /dev/null +++ b/marklogic-client-api/src/test/java/com/marklogic/client/impl/CtsParamExprTest.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + */ +package com.marklogic.client.impl; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.marklogic.client.expression.PlanBuilder; +import com.marklogic.client.io.JacksonHandle; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CtsParamExprTest { + + @Test + void exportsCtsParamInCollectionQuery() { + PlanBuilderSubImpl p = new PlanBuilderSubImpl(); + + PlanBuilder.ModifyPlan employeesPlan = p + .fromView("main", "employees") + .select(p.col("EmployeeID"), p.col("FirstName"), p.col("LastName")) + .where(p.cts.collectionQuery(p.cts.param("collection"))); + + JacksonHandle handle = new JacksonHandle(); + employeesPlan.export(handle); + ObjectNode exportNode = (ObjectNode) handle.get(); + + assertEquals("op", exportNode.path("$optic").path("ns").asText()); + assertEquals("operators", exportNode.path("$optic").path("fn").asText()); + assertEquals("from-view", exportNode.path("$optic").path("args").get(0).path("fn").asText()); + assertEquals("select", exportNode.path("$optic").path("args").get(1).path("fn").asText()); + assertEquals("where", exportNode.path("$optic").path("args").get(2).path("fn").asText()); + assertEquals("collection-query", exportNode.path("$optic").path("args").get(2).path("args").get(0).path("fn").asText()); + assertEquals("param", exportNode.path("$optic").path("args").get(2).path("args").get(0).path("args").get(0).path("fn").asText()); + assertEquals("cts", exportNode.path("$optic").path("args").get(2).path("args").get(0).path("args").get(0).path("ns").asText()); + assertEquals("collection", exportNode.path("$optic").path("args").get(2).path("args").get(0).path("args").get(0).path("args").get(0).asText()); + } + + @Test + void rejectsOpParamInCtsNamespace() { + PlanBuilderSubImpl p = new PlanBuilderSubImpl(); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> p.cts.collectionQuery(p.param("collection")) + ); + + assertEquals( + "Cannot pass op:param() to cts:collection-query(). Use cts:param() for cts namespace expressions.", + ex.getMessage() + ); + } +}