diff --git a/.gitignore b/.gitignore index d933dc310..1aa4bb82c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ xml-with-props.xml ml-development-tools/src/test/ml-modules ml-development-tools/src/test/java/com/marklogic/client/test/dbfunction/generated +.codesight .vscode docker/ 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 1ca46ffb1..5088e3b57 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 @@ -20,6 +20,7 @@ import com.marklogic.client.type.CtsBoxSeqExpr; import com.marklogic.client.type.CtsCircleExpr; import com.marklogic.client.type.CtsCircleSeqExpr; +import com.marklogic.client.type.CtsParamExpr; import com.marklogic.client.type.CtsPeriodExpr; import com.marklogic.client.type.CtsPeriodSeqExpr; import com.marklogic.client.type.CtsPointExpr; @@ -2555,7 +2556,7 @@ public interface CtsExpr { * @return a server expression with the xs:anyAtomicType server data type * @since 8.2.0 */ - public ServerExpression param(String name); + public CtsParamExpr param(String name); /** * Returns a parameter placeholder for a cts expression. * @@ -2565,7 +2566,7 @@ public interface CtsExpr { * @return a server expression with the xs:anyAtomicType server data type * @since 8.2.0 */ - public ServerExpression param(XsStringVal name); + public CtsParamExpr 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/expression/PlanBuilderBase.java b/marklogic-client-api/src/main/java/com/marklogic/client/expression/PlanBuilderBase.java index b1321c575..8fbdab0a7 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/expression/PlanBuilderBase.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/expression/PlanBuilderBase.java @@ -674,6 +674,47 @@ interface PlanBase { * @return a new instance of the Plan object with the parameter binding */ PlanBuilder.Plan bindParam(PlanParamExpr param, String literal); + /** + * Specifies a CTS query expression to replace a placeholder parameter during this + * execution of the plan in all expressions in which the parameter appears. + *
As when building a plan, binding a parameter constructs a new instance + * of the plan with the binding instead of mutating the existing instance + * of the plan.
+ * @param paramName the name of a placeholder parameter as defined by {@code cts.param()} + * or {@code param()} (i.e. op:param) + * @param query the CTS query expression to replace the parameter + * @return a new instance of the Plan object with the parameter binding + * @since 8.2.0; requires MarkLogic 12.1 or higher + */ + PlanBuilder.Plan bindParam(String paramName, CtsQueryExpr query); + /** + * Specifies a CTS query expression to replace a placeholder parameter during this + * execution of the plan in all expressions in which the parameter appears. + *As when building a plan, binding a parameter constructs a new instance + * of the plan with the binding instead of mutating the existing instance + * of the plan.
+ * @param param a placeholder parameter as constructed by cts:param() + * @param query the CTS query expression to replace the parameter + * @return a new instance of the Plan object with the parameter binding + * @since 8.2.0; requires MarkLogic 12.1 or higher + */ + PlanBuilder.Plan bindParam(CtsParamExpr param, CtsQueryExpr query); + /** + * Specifies a CTS query expression to replace a placeholder parameter during this + * execution of the plan in all expressions in which the parameter appears. + *Pass the same {@code PlanParamExpr} returned by {@code param()} to both + * a plan step (such as {@code where()}) and this method. The client substitutes + * the query expression into the serialised plan AST before it is sent to the + * server.
+ *As when building a plan, binding a parameter constructs a new instance + * of the plan with the binding instead of mutating the existing instance + * of the plan.
+ * @param param a placeholder parameter as constructed by {@code param()} + * @param query the CTS query expression to replace the parameter + * @return a new instance of the Plan object with the parameter binding + * @since 8.2.0; requires MarkLogic 12.1 or higher + */ + PlanBuilder.Plan bindParam(PlanParamExpr param, CtsQueryExpr query); /** * Specifies a set of documents to replace a placeholder parameter during this * execution of the plan in all expressions in which the parameter appears. 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 86069b8c3..b59d21f2c 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 @@ -3,9 +3,16 @@ */ package com.marklogic.client.impl; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.core.JsonProcessingException; + import java.lang.reflect.Array; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; @@ -13,6 +20,8 @@ import com.marklogic.client.type.*; public class BaseTypeImpl { + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + public static interface BaseArgImpl { public StringBuilder exportAst(StringBuilder strb); } @@ -434,6 +443,119 @@ static private boolean containsPlanParam(Object value) { return false; } + static String getCtsParamName(CtsParamExpr param) { + if (param == null) { + throw new IllegalArgumentException("param for cts query binding cannot be null"); + } + if (!(param instanceof BaseCallImpl)) { + throw new IllegalArgumentException("param for cts query binding must be of type CtsParamExpr"); + } + + BaseCallImpl> paramCall = (BaseCallImpl>) param; + if (!"cts".equals(paramCall.fnPrefix) || !"param".equals(paramCall.fnName)) { + throw new IllegalArgumentException("param for cts query binding must be of type CtsParamExpr"); + } + + BaseArgImpl[] args = paramCall.getArgsImpl(); + if (args == null || args.length != 1 || !(args[0] instanceof XsStringVal)) { + throw new IllegalArgumentException("CtsParamExpr must have exactly one XsStringVal argument"); + } + + String paramName = ((XsStringVal) args[0]).getString(); + if (paramName == null) { + throw new IllegalArgumentException("CtsParamExpr name cannot be null"); + } + + return paramName; + } + + static String bindCtsQueryParamsInAst(String planAst, MapThe plan mirrors the non-parameterized {@code testSearch} test in + * {@code RowManagerTest}: {@code fromSearch()} joined to the + * {@code opticUnitTest.musician_ml10} view, filtered to trumpet players and + * ordered by last name. Binding {@code cts:jsonPropertyValueQuery} to the + * placeholder must produce the same two rows (Armstrong, Davis).
+ */ + @Test + void roundtripFromSearchWithCtsParamBinding() { + RowManager rowMgr = Common.client.newRowManager(); + PlanBuilder p = rowMgr.newPlanBuilder(); + + PlanSystemColumn viewDocId = p.fragmentIdCol("viewDocId"); + CtsParamExpr searchParam = p.cts.param("searchQuery"); + + PlanBuilder.Plan plan = p + // CtsParamExpr extends ServerExpression, not CtsQueryExpr, so it cannot be passed + // directly to fromSearch(CtsQueryExpr). Wrapping in andQuery(ServerExpression) + // produces a CtsQueryExpr while keeping the cts:param node intact in the AST. + .fromSearch(p.cts.andQuery(searchParam)) + .joinInner( + p.fromView("opticUnitTest", "musician_ml10", "", viewDocId), + p.on(p.fragmentIdCol("fragmentId"), viewDocId) + ) + .orderBy(p.col("lastName")) + .bindParam(searchParam, p.cts.jsonPropertyValueQuery("instrument", "trumpet")); + + String[] expectedLastName = {"Armstrong", "Davis"}; + String[] expectedFirstName = {"Louis", "Miles"}; + + int rowNum = 0; + for (RowRecord row : rowMgr.resultRows(plan)) { + assertEquals(expectedLastName[rowNum], row.getString("lastName")); + assertEquals(expectedFirstName[rowNum], row.getString("firstName")); + rowNum++; + } + assertEquals(2, rowNum); + } + + // --------------------------------------------------------------------------- + // op:param() in where() – unit tests + // --------------------------------------------------------------------------- + + @Test + void whereWithOpParamProducesCorrectAst() { + PlanBuilderSubImpl p = new PlanBuilderSubImpl(); + + PlanBuilder.ModifyPlan plan = p + .fromView("main", "employees") + .where(p.param("query")); + + JacksonHandle handle = new JacksonHandle(); + plan.export(handle); + ObjectNode exportNode = (ObjectNode) handle.get(); + + // where() args[0] must be an op:param node + ObjectNode whereArgs0 = (ObjectNode) exportNode.path("$optic").path("args").get(1).path("args").get(0); + assertEquals("op", whereArgs0.path("ns").asText(), "namespace should be op"); + assertEquals("param", whereArgs0.path("fn").asText(), "function should be param"); + assertEquals("query", whereArgs0.path("args").get(0).path("args").get(0).asText(), "param name"); + } + + @Test + void bindsOpParamToQueryByString() throws Exception { + PlanBuilderSubImpl p = new PlanBuilderSubImpl(); + + PlanBuilder.Plan plan = p + .fromView("main", "employees") + .where(p.param("query")) + .bindParam("query", p.cts.wordQuery("needle")); + + String ast = ((StringHandle) ((PlanBuilderBaseImpl.RequestPlan) plan).getHandle()).get(); + ObjectNode whereArgs0 = (ObjectNode) new ObjectMapper().readTree(ast) + .path("$optic").path("args").get(1).path("args").get(0); + + // The op:param node must have been replaced by the bound word-query + assertEquals("cts", whereArgs0.path("ns").asText()); + assertEquals("word-query", whereArgs0.path("fn").asText()); + } + + @Test + void bindsOpParamToQueryViaParamExpr() throws Exception { + PlanBuilderSubImpl p = new PlanBuilderSubImpl(); + PlanParamExpr queryParam = p.param("query"); + + PlanBuilder.Plan plan = p + .fromView("main", "employees") + .where(queryParam) + .bindParam(queryParam, p.cts.trueQuery()); + + String ast = ((StringHandle) ((PlanBuilderBaseImpl.RequestPlan) plan).getHandle()).get(); + ObjectNode whereArgs0 = (ObjectNode) new ObjectMapper().readTree(ast) + .path("$optic").path("args").get(1).path("args").get(0); + + assertEquals("cts", whereArgs0.path("ns").asText()); + assertEquals("true-query", whereArgs0.path("fn").asText()); + } + + @Test + void rejectsNullParamExprForOpParamCtsBinding() { + PlanBuilderSubImpl p = new PlanBuilderSubImpl(); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> p.fromView("main", "employees").bindParam((PlanParamExpr) null, p.cts.wordQuery("needle")) + ); + assertTrue(ex.getMessage().contains("param")); + } + + @Test + void rejectsNullQueryForOpParamCtsBinding() { + PlanBuilderSubImpl p = new PlanBuilderSubImpl(); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> p.fromView("main", "employees").bindParam(p.param("query"), (CtsQueryExpr) null) + ); + assertTrue(ex.getMessage().contains("query")); + } + + @Test + void unboundOpParamIsPreservedInAst() { + PlanBuilderSubImpl p = new PlanBuilderSubImpl(); + + PlanBuilder.ModifyPlan plan = p + .fromView("main", "employees") + .where(p.param("query")); + + // No bindParam call – op:param node must survive in the exported AST + JacksonHandle handle = new JacksonHandle(); + plan.export(handle); + ObjectNode whereArgs0 = (ObjectNode) handle.get() + .path("$optic").path("args").get(1).path("args").get(0); + + assertEquals("op", whereArgs0.path("ns").asText()); + assertEquals("param", whereArgs0.path("fn").asText()); + } + + @Test + void stringKeyedBindingSubstitutesBothCtsParamAndOpParam() throws Exception { + // A single bindParam(String, CtsQueryExpr) call must substitute ALL param nodes – + // both cts:param and op:param – that share the given name in the same plan. + PlanBuilderSubImpl p = new PlanBuilderSubImpl(); + + PlanBuilder.Plan plan = p + .fromView("main", "employees") + // op:param("myParam") used directly in where() + .where(p.param("myParam")) + // cts:param("myParam") nested inside a cts expression in a second where() + .where(p.cts.jsonPropertyScopeQuery("prop", p.cts.param("myParam"))) + .bindParam("myParam", p.cts.wordQuery("needle")); + + String ast = ((StringHandle) ((PlanBuilderBaseImpl.RequestPlan) plan).getHandle()).get(); + JsonNode root = new ObjectMapper().readTree(ast).path("$optic"); + + // First where: op:param in where() must be replaced with word-query + JsonNode firstWhereArg = root.path("args").get(1).path("args").get(0); + assertEquals("cts", firstWhereArg.path("ns").asText(), "op:param in where() must be substituted"); + assertEquals("word-query", firstWhereArg.path("fn").asText(), "op:param in where() must be substituted"); + + // Second where: cts:param inside jsonPropertyScopeQuery must also be replaced with word-query + JsonNode scopeQueryArg1 = root.path("args").get(2).path("args").get(0).path("args").get(1); + assertEquals("cts", scopeQueryArg1.path("ns").asText(), "cts:param in jsonPropertyScopeQuery must be substituted"); + assertEquals("word-query", scopeQueryArg1.path("fn").asText(), "cts:param in jsonPropertyScopeQuery must be substituted"); + } + + // --------------------------------------------------------------------------- + // Integration (roundtrip) test – op:param in where() + // --------------------------------------------------------------------------- + + /** + * Verifies end-to-end that an {@code op:param()} placeholder used directly in + * {@code where()} and bound to a {@link CtsQueryExpr} via {@code bindParam} is + * substituted before the plan is sent to MarkLogic and that the server returns + * the expected rows. + * + *The plan queries the {@code opticUnitTest.musician_ml10} view, filters via + * a {@code where(op.param())} bound to {@code cts:jsonPropertyValueQuery}, and + * orders by last name. The expected result is Armstrong and Davis (trumpet + * players), matching the behaviour verified by + * {@link #roundtripFromSearchWithCtsParamBinding()}.
+ */ + @Test + void roundtripWhereWithOpParamBinding() { + RowManager rowMgr = Common.client.newRowManager(); + PlanBuilder p = rowMgr.newPlanBuilder(); + + PlanParamExpr queryParam = p.param("searchQuery"); + + PlanBuilder.Plan plan = p + .fromView("opticUnitTest", "musician_ml10") + .where(queryParam) + .orderBy(p.col("lastName")) + .bindParam(queryParam, p.cts.jsonPropertyValueQuery("instrument", "trumpet")); + + String[] expectedLastName = {"Armstrong", "Davis"}; + String[] expectedFirstName = {"Louis", "Miles"}; + + int rowNum = 0; + for (RowRecord row : rowMgr.resultRows(plan)) { + assertEquals(expectedLastName[rowNum], row.getString("lastName")); + assertEquals(expectedFirstName[rowNum], row.getString("firstName")); + rowNum++; + } + assertEquals(2, rowNum); + } + + @Test + void roundtripWhereWithOpParamBindingInCtsOr() { + RowManager rowMgr = Common.client.newRowManager(); + PlanBuilder p = rowMgr.newPlanBuilder(); + + CtsParamExpr queryParam = p.cts.param("searchQuery"); + + PlanBuilder.Plan plan = p + .fromView("opticUnitTest", "musician_ml10") + .where( + p.cts.orQuery( + p.cts.jsonPropertyValueQuery("instrument", "vocal"), + queryParam + ) + ) + .orderBy(p.col("lastName")) + .bindParam(queryParam, p.cts.jsonPropertyValueQuery("instrument", "trumpet")); + + String[] expectedLastName = {"Armstrong", "Davis"}; + String[] expectedFirstName = {"Louis", "Miles"}; + + int rowNum = 0; + for (RowRecord row : rowMgr.resultRows(plan)) { + assertEquals(expectedLastName[rowNum], row.getString("lastName")); + assertEquals(expectedFirstName[rowNum], row.getString("firstName")); + rowNum++; + } + assertEquals(2, rowNum); + } } diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/impl/PlanRowColTypesImplTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/impl/PlanRowColTypesImplTest.java index a79f91edd..ca4302354 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/impl/PlanRowColTypesImplTest.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/impl/PlanRowColTypesImplTest.java @@ -1,11 +1,11 @@ /* - * 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; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; -import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/RowManagerTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/RowManagerTest.java index c6adf5983..416263ad9 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/RowManagerTest.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/RowManagerTest.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.test.rows; @@ -589,6 +589,128 @@ public void testSearch() { } assertEquals( 2, rowNum); } + @Test + public void testSearchWithCtsParamQueryBindingByName() { + RowManager rowMgr = Common.client.newRowManager(); + + PlanBuilder p = rowMgr.newPlanBuilder(); + PlanSystemColumn viewDocId = p.fragmentIdCol("viewDocId"); + + PlanBuilder.ExportablePlan builtPlan = + p.fromSearch(p.cts.andQuery(p.cts.param("searchQuery"))) + .joinInner( + p.fromView("opticUnitTest", VIEW_NAME, "", viewDocId), + p.on(p.fragmentIdCol("fragmentId"), viewDocId) + ) + .orderBy(p.col("lastName")); + + PlanBuilder.Plan boundPlan = builtPlan.bindParam( + "searchQuery", + p.cts.jsonPropertyValueQuery("instrument", "trumpet") + ); + + String[] lastName = {"Armstrong", "Davis"}; + String[] firstName = {"Louis", "Miles"}; + + int rowNum = 0; + for (RowRecord row: rowMgr.resultRows(boundPlan)) { + assertEquals(lastName[rowNum], row.getString("lastName")); + assertEquals(firstName[rowNum], row.getString("firstName")); + rowNum++; + } + assertEquals(2, rowNum); + } + + @Test + public void testSearchWithCtsParamQueryBindingByExpression() { + RowManager rowMgr = Common.client.newRowManager(); + + PlanBuilder p = rowMgr.newPlanBuilder(); + PlanSystemColumn viewDocId = p.fragmentIdCol("viewDocId"); + CtsParamExpr searchQueryParam = p.cts.param("searchQuery"); + + PlanBuilder.ExportablePlan builtPlan = + p.fromSearch(p.cts.andQuery(searchQueryParam)) + .joinInner( + p.fromView("opticUnitTest", VIEW_NAME, "", viewDocId), + p.on(p.fragmentIdCol("fragmentId"), viewDocId) + ) + .orderBy(p.col("lastName")); + + PlanBuilder.Plan boundPlan = builtPlan.bindParam( + searchQueryParam, + p.cts.jsonPropertyValueQuery("instrument", "trumpet") + ); + + String[] lastName = {"Armstrong", "Davis"}; + String[] firstName = {"Louis", "Miles"}; + + int rowNum = 0; + for (RowRecord row: rowMgr.resultRows(boundPlan)) { + assertEquals(lastName[rowNum], row.getString("lastName")); + assertEquals(firstName[rowNum], row.getString("firstName")); + rowNum++; + } + assertEquals(2, rowNum); + } + + @Test + public void testSearchWithTwoCtsParamBindings() { + RowManager rowMgr = Common.client.newRowManager(); + + PlanBuilder p = rowMgr.newPlanBuilder(); + PlanSystemColumn viewDocId = p.fragmentIdCol("viewDocId"); + + // Build a plan parameterized by two independent cts:param() placeholders. + // Binding both to queries that together restrict results to a single row + // verifies each substitution is applied independently. + // + // CtsParamExpr extends ServerExpression but not CtsQueryExpr, so each param is wrapped + // in a single-arg andQuery(ServerExpression) to produce a CtsQueryExpr, then composed + // in the outer varargs andQuery(CtsQueryExpr...). + PlanBuilder.ExportablePlan builtPlan = + p.fromSearch(p.cts.andQuery( + p.cts.andQuery(p.cts.param("instrumentQuery")), + p.cts.andQuery(p.cts.param("lastNameQuery")) + )) + .joinInner( + p.fromView("opticUnitTest", VIEW_NAME, "", viewDocId), + p.on(p.fragmentIdCol("fragmentId"), viewDocId) + ) + .orderBy(p.col("lastName")); + + PlanBuilder.Plan boundPlan = builtPlan + .bindParam("instrumentQuery", p.cts.jsonPropertyValueQuery("instrument", "trumpet")) + .bindParam("lastNameQuery", p.cts.jsonPropertyValueQuery("lastName", "Armstrong")); + + int rowNum = 0; + for (RowRecord row : rowMgr.resultRows(boundPlan)) { + assertEquals("Armstrong", row.getString("lastName")); + assertEquals("Louis", row.getString("firstName")); + rowNum++; + } + assertEquals(1, rowNum); + } + + @Test + public void testRawPlanThrowsForCtsQueryBinding() { + RowManager rowMgr = Common.client.newRowManager(); + + PlanBuilder p = rowMgr.newPlanBuilder(); + PlanBuilder.ExportablePlan builtPlan = p.fromView("opticUnitTest", VIEW_NAME); + StringHandle planHandle = builtPlan.export(new StringHandle()).withFormat(Format.JSON); + RawPlanDefinition rawPlan = rowMgr.newRawPlanDefinition(planHandle); + + assertThrows(UnsupportedOperationException.class, + () -> rawPlan.bindParam("searchQuery", p.cts.wordQuery("trumpet")) + ); + + CtsParamExpr param = p.cts.param("searchQuery"); + assertThrows(UnsupportedOperationException.class, + () -> rawPlan.bindParam(param, p.cts.wordQuery("trumpet")) + ); + } + @Test public void testSearchDocs() { RowManager rowMgr = Common.client.newRowManager();