implements PlanParamExpr {
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 a4e23db9e..0310dbd1e 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
@@ -48,7 +48,7 @@ public PlanBuilder.AccessPlan fromSearchDocs(CtsQueryExpr query) {
}
@Override
public PlanBuilder.AccessPlan fromSearchDocs(CtsQueryExpr query, String qualifierName) {
- return fromSearchDocs(query, null, null);
+ return fromSearchDocs(query, qualifierName, null);
}
@Override
public PlanBuilder.AccessPlan fromSearchDocs(CtsQueryExpr query, String qualifierName, PlanSearchOptions options) {
@@ -694,7 +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/main/java/com/marklogic/client/type/PlanSearchOptions.java b/marklogic-client-api/src/main/java/com/marklogic/client/type/PlanSearchOptions.java
index dbec40972..4a0cb1315 100644
--- a/marklogic-client-api/src/main/java/com/marklogic/client/type/PlanSearchOptions.java
+++ b/marklogic-client-api/src/main/java/com/marklogic/client/type/PlanSearchOptions.java
@@ -1,11 +1,15 @@
/*
- * 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.type;
/**
- * An option controlling the scoring and weighting of fromSearch()
- * for a row pipeline.
+ * Options controlling the scoring, weighting, and fragment scope for {@code fromSearch()} and
+ * {@code fromSearchDocs()} in a row pipeline. Use {@link #withFragment(Fragment)} to select which
+ * fragment types (document, properties, locks, or any) are searched and returned.
+ *
+ * Fragment scope support was added in release 8.2.0 and requires MarkLogic 12.1 or higher.
+ * Scoring and weighting options apply to all supported MarkLogic versions.
*/
public interface PlanSearchOptions {
@@ -38,6 +42,34 @@ public interface PlanSearchOptions {
*/
PlanSearchOptions withBm25LengthWeight(double bm25LengthWeight);
+ /**
+ * @since 8.2.0; requires MarkLogic 12.1 or higher.
+ */
+ Fragment getFragment();
+
+ /**
+ * Specifies the type of fragment to search and return. Defaults to {@link Fragment#DOCUMENT} when no option
+ * is specified. Applies to both {@code fromSearch()} and {@code fromSearchDocs()}.
+ *
+ * @param fragment the fragment scope to select
+ * @return a new PlanSearchOptions with the fragment set
+ * @since 8.2.0; requires MarkLogic 12.1 or higher.
+ */
+ PlanSearchOptions withFragment(Fragment fragment);
+
+ /**
+ * Controls which type of fragments are searched and returned by {@code fromSearch()} and
+ * {@code fromSearchDocs()}.
+ *
+ * @since 8.2.0; requires MarkLogic 12.1 or higher.
+ */
+ enum Fragment {
+ DOCUMENT,
+ ANY,
+ PROPERTIES,
+ LOCKS
+ }
+
enum ScoreMethod {
LOGTFIDF,
LOGTF,
diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/junit5/RequiresML12Dot1.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/junit5/RequiresML12Dot1.java
new file mode 100644
index 000000000..9f2dc9f80
--- /dev/null
+++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/junit5/RequiresML12Dot1.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
+ */
+package com.marklogic.client.test.junit5;
+
+import com.marklogic.client.test.Common;
+import com.marklogic.client.test.MarkLogicVersion;
+import org.junit.jupiter.api.extension.ConditionEvaluationResult;
+import org.junit.jupiter.api.extension.ExecutionCondition;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+public class RequiresML12Dot1 implements ExecutionCondition {
+
+ private static MarkLogicVersion markLogicVersion;
+
+ @Override
+ public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
+ if (markLogicVersion == null) {
+ markLogicVersion = Common.getMarkLogicVersion();
+ }
+ boolean supported =
+ (markLogicVersion.getMajor() == 12 && markLogicVersion.getMinor() != null && markLogicVersion.getMinor() >= 1) ||
+ markLogicVersion.getMajor() > 12;
+ return supported ?
+ ConditionEvaluationResult.enabled("MarkLogic is version 12.1 or higher") :
+ ConditionEvaluationResult.disabled("MarkLogic is version 12.0.x or lower");
+ }
+}
diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchDocsWithFragmentTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchDocsWithFragmentTest.java
new file mode 100644
index 000000000..b58b61291
--- /dev/null
+++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchDocsWithFragmentTest.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
+ */
+package com.marklogic.client.test.rows;
+
+import com.marklogic.client.io.JacksonHandle;
+import com.marklogic.client.row.RowRecord;
+import com.marklogic.client.test.Common;
+import com.marklogic.client.test.junit5.RequiresML12Dot1;
+import com.marklogic.client.type.PlanSearchOptions;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for the {@link PlanSearchOptions.Fragment} option added by MLE-28334, using
+ * {@code op.fromSearchDocs()}. All tests require MarkLogic 12.1 or higher because the
+ * {@code fragment} option was introduced in MarkLogic 12.1.
+ *
+ * Note: {@code op.fromSearchDocs()} returns rows with {@code uri} and {@code doc}
+ * columns directly — URI assertions are possible for all fragment types without requiring
+ * a separate {@code joinDocUri()} call.
+ *
+ * @see FromSearchWithFragmentTest for equivalent tests using {@code op.fromSearch()}.
+ */
+@ExtendWith(RequiresML12Dot1.class)
+class FromSearchDocsWithFragmentTest extends AbstractOpticUpdateTest {
+
+ private static final String SETUP_XQUERY =
+ "xquery version '1.0-ml';" +
+ "let $jsondoc1 := object-node {'AllDataTypes': array-node {object-node {'word':'dog'}, object-node {'rank':1}, object-node {'score':4}}}" +
+ "let $jsondoc2 := object-node {'AllDataTypes': array-node {object-node {'word':'cat'}, object-node {'rank':2}, object-node {'score':5}}}" +
+ "let $jsondoc3 := object-node {'AllDataTypes': array-node {object-node {'word':'duck'}, object-node {'rank':3}, object-node {'score':6}}}" +
+ "return (" +
+ "xdmp:document-insert('range-prop-1.json', $jsondoc1, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
+ "xdmp:document-insert('range-prop-2.json', $jsondoc2, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
+ "xdmp:document-insert('range-prop-3.json', $jsondoc3, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
+ "xdmp:document-set-properties('range-prop-1.json', (opticfragmentpropvalue prop1value))," +
+ "xdmp:document-set-properties('range-prop-2.json', (opticfragmentpropvalue prop2value))," +
+ "xdmp:document-set-properties('range-prop-3.json', (opticfragmentpropvalue prop3value))," +
+ "xdmp:lock-acquire('range-prop-1.json', 'exclusive', '0', 'dog rose', xs:unsignedLong(120))," +
+ "xdmp:lock-acquire('range-prop-2.json', 'exclusive', '0', 'cat tulip', xs:unsignedLong(120))," +
+ "xdmp:lock-acquire('range-prop-3.json', 'exclusive', '0', 'duck lily', xs:unsignedLong(120))" +
+ ")";
+
+ private static final String TEARDOWN_XQUERY =
+ "xquery version '1.0-ml';" +
+ "for $uri in ('range-prop-1.json', 'range-prop-2.json', 'range-prop-3.json') return xdmp:document-delete($uri)";
+
+ private static final List EXPECTED_URIS = List.of(
+ "range-prop-1.json", "range-prop-2.json", "range-prop-3.json");
+
+ @BeforeEach
+ void setupTest() {
+ rowManager.withUpdate(false);
+ Common.newEvalClient().newServerEval().xquery(SETUP_XQUERY).evalAs(String.class);
+ }
+
+ @AfterEach
+ void teardownTest() {
+ Common.newEvalClient().newServerEval().xquery(TEARDOWN_XQUERY).evalAs(String.class);
+ }
+
+ /**
+ * Test case: Verifies the default {@code fromSearchDocs} behavior when no {@code fragment} option
+ * is specified. On MarkLogic 12.1, the default is to search document fragments only. "duck"
+ * appears in the document content of range-prop-3.json ({@code {"word":"duck"}}), so exactly
+ * one row is returned with {@code uri} equal to range-prop-3.json. Properties and lock fragments
+ * are not searched by default.
+ */
+ @Test
+ void fromSearchDocsDefaultFragment() {
+ List rows = resultRows(
+ op.fromSearchDocs(
+ op.cts.wordQuery("duck"),
+ null,
+ null
+ )
+ );
+ assertEquals(1, rows.size());
+ assertEquals("range-prop-3.json", rows.get(0).getString("uri"));
+ }
+
+ /**
+ * Test case: With persistent locks on the 3 test documents, use {@code fromSearchDocs} with the
+ * {@link PlanSearchOptions.Fragment#LOCKS} option to find a document via its lock holder text.
+ * "rose" appears only in the lock holder for range-prop-1.json ("dog rose"), not in any document
+ * content, so exactly one row is returned and resolves to range-prop-1.json, proving the search
+ * targeted lock fragments.
+ */
+ @Test
+ void fromSearchDocsWithLocksFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.LOCKS);
+ List rows = resultRows(
+ op.fromSearchDocs(
+ op.cts.locksFragmentQuery(op.cts.wordQuery("rose")),
+ null,
+ options
+ )
+ );
+ assertEquals(1, rows.size());
+ assertEquals("range-prop-1.json", rows.get(0).getString("uri"));
+ }
+
+ /**
+ * Test case: "prop2value" appears only in the property of range-prop-2.json, proving the search
+ * is scoped to properties fragments. Exactly one row is returned and resolves to range-prop-2.json.
+ */
+ @Test
+ void fromSearchDocsWithPropertiesFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.PROPERTIES);
+ List rows = resultRows(
+ op.fromSearchDocs(
+ op.cts.propertiesFragmentQuery(op.cts.wordQuery("prop2value")),
+ null,
+ options
+ )
+ );
+ assertEquals(1, rows.size());
+ assertEquals("range-prop-2.json", rows.get(0).getString("uri"));
+ }
+
+ /**
+ * Test case: "duck" appears only in the document content of range-prop-3.json ({@code {"word":"duck"}}),
+ * not in any lock holder or property. With {@link PlanSearchOptions.Fragment#DOCUMENT}, exactly
+ * one document fragment matches and resolves to range-prop-3.json via the {@code uri} column
+ * returned directly by {@code fromSearchDocs}.
+ */
+ @Test
+ void fromSearchDocsWithDocumentFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.DOCUMENT);
+ List rows = resultRows(
+ op.fromSearchDocs(
+ op.cts.wordQuery("duck"),
+ null,
+ options
+ )
+ );
+ assertEquals(1, rows.size());
+ assertEquals("range-prop-3.json", rows.get(0).getString("uri"));
+ }
+
+ /**
+ * Test case: "opticfragmentpropvalue" appears only in the properties fragment of each test
+ * document (not in document content or lock holders). With {@link PlanSearchOptions.Fragment#ANY},
+ * all 3 properties fragments match. Unlike {@code fromSearch()}, {@code fromSearchDocs()} with
+ * {@code ANY} returns multiple rows per matched document (one per fragment type present on that
+ * document). The test asserts that all 3 test document URIs appear among the results.
+ */
+ @Test
+ void fromSearchDocsWithAnyFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.ANY);
+ List rows = resultRows(
+ op.fromSearchDocs(
+ op.cts.propertiesFragmentQuery(op.cts.wordQuery("opticfragmentpropvalue")),
+ null,
+ options
+ )
+ );
+ // fromSearchDocs with ANY returns one row per fragment type per matched document (3 docs × 3 types = 9 rows)
+ assertEquals(9, rows.size(), "ANY fragment should return 3 rows per document (document + properties + locks)");
+ assertEquals(EXPECTED_URIS, rows.stream().map(r -> r.getString("uri")).distinct().sorted().collect(Collectors.toList()));
+ }
+
+ /**
+ * Test case: Verifies that {@code explain()} serialises a plan that uses {@code fromSearchDocs}
+ * with the {@code fragment} option. Calls {@code explain()} on a plan that searches lock fragments
+ * for the word "dog" (matches only range-prop-1.json whose lock holder is "dog rose"). Verifies
+ * that {@code explain()} returns a non-null JSON node, confirming the plan with the
+ * {@code fragment} option can be serialised without error.
+ */
+ @Test
+ void explainFromSearchDocsWithLocksFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.LOCKS);
+
+ JacksonHandle explainHandle = rowManager.explain(
+ op.fromSearchDocs(
+ op.cts.locksFragmentQuery(op.cts.wordQuery("dog")),
+ null,
+ options
+ )
+ .orderBy(op.col("uri"))
+ .select(op.col("uri"), op.col("doc")),
+ new JacksonHandle()
+ );
+ assertNotNull(explainHandle.get(), "explain() must return a non-null plan JSON node");
+ }
+}
diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchWithFragmentTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchWithFragmentTest.java
new file mode 100644
index 000000000..ea3664e7b
--- /dev/null
+++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchWithFragmentTest.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
+ */
+package com.marklogic.client.test.rows;
+
+import com.marklogic.client.io.JacksonHandle;
+import com.marklogic.client.row.RowRecord;
+import com.marklogic.client.test.Common;
+import com.marklogic.client.test.junit5.RequiresML12Dot1;
+import com.marklogic.client.type.PlanSearchOptions;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for the {@link PlanSearchOptions.Fragment} option added by MLE-28334, using
+ * {@code op.fromSearch()}. All tests require MarkLogic 12.1 or higher because the
+ * {@code fragment} option was introduced in MarkLogic 12.1.
+ *
+ * Note: {@code op.fromSearch()} returns rows with {@code fragmentId} and {@code score}
+ * columns only — there is no {@code uri} column. {@code joinDocUri()} can resolve document
+ * fragment IDs to URIs, but does not support lock or properties fragment IDs on MarkLogic 12.1.
+ *
+ * @see FromSearchDocsWithFragmentTest for equivalent tests using {@code op.fromSearchDocs()}.
+ */
+@ExtendWith(RequiresML12Dot1.class)
+class FromSearchWithFragmentTest extends AbstractOpticUpdateTest {
+
+ private static final String SETUP_XQUERY =
+ "xquery version '1.0-ml';" +
+ "let $jsondoc1 := object-node {'AllDataTypes': array-node {object-node {'word':'dog'}, object-node {'rank':1}, object-node {'score':4}}}" +
+ "let $jsondoc2 := object-node {'AllDataTypes': array-node {object-node {'word':'cat'}, object-node {'rank':2}, object-node {'score':5}}}" +
+ "let $jsondoc3 := object-node {'AllDataTypes': array-node {object-node {'word':'duck'}, object-node {'rank':3}, object-node {'score':6}}}" +
+ "return (" +
+ "xdmp:document-insert('range-prop-1.json', $jsondoc1, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
+ "xdmp:document-insert('range-prop-2.json', $jsondoc2, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
+ "xdmp:document-insert('range-prop-3.json', $jsondoc3, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
+ "xdmp:document-set-properties('range-prop-1.json', (opticfragmentpropvalue prop1value))," +
+ "xdmp:document-set-properties('range-prop-2.json', (opticfragmentpropvalue prop2value))," +
+ "xdmp:document-set-properties('range-prop-3.json', (opticfragmentpropvalue prop3value))," +
+ "xdmp:lock-acquire('range-prop-1.json', 'exclusive', '0', 'dog rose', xs:unsignedLong(120))," +
+ "xdmp:lock-acquire('range-prop-2.json', 'exclusive', '0', 'cat tulip', xs:unsignedLong(120))," +
+ "xdmp:lock-acquire('range-prop-3.json', 'exclusive', '0', 'duck lily', xs:unsignedLong(120))" +
+ ")";
+
+ private static final String TEARDOWN_XQUERY =
+ "xquery version '1.0-ml';" +
+ "for $uri in ('range-prop-1.json', 'range-prop-2.json', 'range-prop-3.json') return xdmp:document-delete($uri)";
+
+ private static final List EXPECTED_URIS = List.of(
+ "range-prop-1.json", "range-prop-2.json", "range-prop-3.json");
+
+ @BeforeEach
+ void setupTest() {
+ rowManager.withUpdate(false);
+ Common.newEvalClient().newServerEval().xquery(SETUP_XQUERY).evalAs(String.class);
+ }
+
+ @AfterEach
+ void teardownTest() {
+ Common.newEvalClient().newServerEval().xquery(TEARDOWN_XQUERY).evalAs(String.class);
+ }
+
+ /**
+ * Test case: Verifies the default {@code fromSearch} behavior when no {@code fragment} option
+ * is specified. On MarkLogic 12.1, the default is to search document fragments only. "duck"
+ * appears in the document content of range-prop-3.json ({@code {"word":"duck"}}), so exactly
+ * one row is returned. Properties and lock fragments are not searched by default.
+ */
+ @Test
+ void fromSearchDefaultFragment() {
+ List rows = resultRows(
+ op.fromSearch(
+ op.cts.wordQuery("duck"),
+ null, null, null
+ ).joinDocUri(op.col("uri"), op.fragmentIdCol("fragmentId"))
+ );
+ assertEquals(1, rows.size());
+ assertEquals("range-prop-3.json", rows.get(0).getString("uri"));
+ }
+
+ /**
+ * Test case: With persistent locks on the 3 test documents, use {@code fromSearch} with the
+ * {@link PlanSearchOptions.Fragment#LOCKS} option to find a document via its lock holder text.
+ * "rose" appears only in the lock holder for range-prop-1.json ("dog rose"), not in any document
+ * content, so exactly one row is returned, proving the search targeted lock fragments.
+ * {@code fromSearch} returns only {@code fragmentId} and {@code score} columns; URI resolution
+ * via {@code joinDocUri} is not supported for lock fragment IDs on MarkLogic 12.1.
+ */
+ @Test
+ void fromSearchWithLocksFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.LOCKS);
+ List rows = resultRows(
+ op.fromSearch(
+ op.cts.locksFragmentQuery(op.cts.wordQuery("rose")),
+ null, null, options
+ )
+ );
+ assertEquals(1, rows.size());
+ assertNotNull(rows.get(0).get("score"),
+ "score column must be present in fromSearch() results");
+ }
+
+ /**
+ * Test case: "prop2value" appears only in the property of range-prop-2.json, proving the search
+ * is scoped to properties fragments. Exactly one row is returned. {@code fromSearch} returns
+ * only {@code fragmentId} and {@code score} columns; URI resolution via {@code joinDocUri} is
+ * not supported for properties fragment IDs on MarkLogic 12.1.
+ */
+ @Test
+ void fromSearchWithPropertiesFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.PROPERTIES);
+ List rows = resultRows(
+ op.fromSearch(
+ op.cts.propertiesFragmentQuery(op.cts.wordQuery("prop2value")),
+ null, null, options
+ )
+ );
+ assertEquals(1, rows.size());
+ assertNotNull(rows.get(0).get("score"),
+ "score column must be present in fromSearch() results");
+ }
+
+ /**
+ * Test case: "duck" appears only in the document content of range-prop-3.json ({@code {"word":"duck"}}),
+ * not in any lock holder or property. With {@link PlanSearchOptions.Fragment#DOCUMENT}, exactly
+ * one document fragment matches and resolves to range-prop-3.json via {@code joinDocUri}.
+ */
+ @Test
+ void fromSearchWithDocumentFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.DOCUMENT);
+ List rows = resultRows(
+ op.fromSearch(
+ op.cts.wordQuery("duck"),
+ null, null, options
+ ).joinDocUri(op.col("uri"), op.fragmentIdCol("fragmentId"))
+ );
+ assertEquals(1, rows.size());
+ assertEquals("range-prop-3.json", rows.get(0).getString("uri"));
+ }
+
+ /**
+ * Test case: "opticfragmentpropvalue" appears only in the properties fragment of each test
+ * document (not in document content or lock holders). With {@link PlanSearchOptions.Fragment#ANY},
+ * all 3 properties fragments match, returning 3 rows with all 3 document URIs.
+ */
+ @Test
+ void fromSearchWithAnyFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.ANY);
+ List rows = resultRows(
+ op.fromSearch(
+ op.cts.propertiesFragmentQuery(op.cts.wordQuery("opticfragmentpropvalue")),
+ null, null, options
+ ).joinDocUri(op.col("uri"), op.fragmentIdCol("fragmentId"))
+ );
+ assertEquals(3, rows.size());
+ assertEquals(EXPECTED_URIS, rows.stream().map(r -> r.getString("uri")).sorted().collect(Collectors.toList()));
+ }
+
+ /**
+ * Test case: Verifies that {@code explain()} serialises a plan that uses the {@code fragment} option.
+ * Calls {@code explain()} on a plan that searches lock fragments for the word "dog"
+ * (matches only range-prop-1.json whose lock holder is "dog rose") and joins to retrieve
+ * lock document content via {@code joinDocAndUri}. Verifies that {@code explain()} returns
+ * a non-null JSON node, confirming the plan with the {@code fragment} option can be
+ * serialised without error.
+ */
+ @Test
+ void explainFromSearchWithLocksFragment() {
+ PlanSearchOptions options = op.searchOptions().withFragment(PlanSearchOptions.Fragment.LOCKS);
+
+ JacksonHandle explainHandle = rowManager.explain(
+ op.fromSearch(
+ op.cts.locksFragmentQuery(op.cts.wordQuery("dog")),
+ null, null, options
+ ).joinDocAndUri(op.col("doc"), op.col("uri"), op.fragmentIdCol("fragmentId"))
+ .orderBy(op.col("uri"))
+ .select(op.col("uri"), op.col("doc")),
+ new JacksonHandle()
+ );
+ assertNotNull(explainHandle.get(), "explain() must return a non-null plan JSON node");
+ }
+}
From 6ff04be3105fa578e5fdc2713df4a6831127888e Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Fri, 15 May 2026 09:45:21 -0400
Subject: [PATCH 2/2] MLE-28334 Copilot Suggested Fixes
+ AbstractFromSearchFragmentTest.java: Refactored FromSearchDocsWithFragmentTest.java and FromSearchWithFragmentTest.java
+ Changed JavaDoc for fromsearchDocs in PlanBuilderBase.java
---
.../client/expression/PlanBuilderBase.java | 9 ++--
.../rows/AbstractFromSearchFragmentTest.java | 54 +++++++++++++++++++
.../rows/FromSearchDocsWithFragmentTest.java | 40 +-------------
.../test/rows/FromSearchWithFragmentTest.java | 40 +-------------
4 files changed, 61 insertions(+), 82 deletions(-)
create mode 100644 marklogic-client-api/src/test/java/com/marklogic/client/test/rows/AbstractFromSearchFragmentTest.java
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 d7bb3f62b..b1321c575 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
@@ -56,11 +56,12 @@ public interface PlanBuilderBase {
* @param query The cts.query expression for matching the documents.
* @param qualifierName Specifies a name for qualifying the column names similar to a view name.
* @param options Specifies scoring options and the fragment type to search. Use
- * {@link PlanBuilder#searchOptions()} with
- * {@link PlanSearchOptions#withFragment(PlanSearchOptions.Fragment)} to control
- * the fragment scope.
+ * {@link PlanBuilder#searchOptions()} to create the options. Support for
+ * controlling fragment scope with
+ * {@link PlanSearchOptions#withFragment(PlanSearchOptions.Fragment)} was added
+ * in 8.2.0 and requires MarkLogic 12.1 or higher.
* @return an AccessPlan object
- * @since 8.2.0; requires MarkLogic 12.1 or higher.
+ * @since 7.0.0
*/
PlanBuilder.AccessPlan fromSearchDocs(CtsQueryExpr query, String qualifierName, PlanSearchOptions options);
/**
diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/AbstractFromSearchFragmentTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/AbstractFromSearchFragmentTest.java
new file mode 100644
index 000000000..bec5c7f59
--- /dev/null
+++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/AbstractFromSearchFragmentTest.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.test.rows;
+
+import com.marklogic.client.test.Common;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import java.util.List;
+
+/**
+ * Shared test fixture for {@link FromSearchWithFragmentTest} and
+ * {@link FromSearchDocsWithFragmentTest}. Holds the XQuery setup/teardown scripts and
+ * expected URI constants so that changes to the test documents only need to be made in
+ * one place.
+ */
+abstract class AbstractFromSearchFragmentTest extends AbstractOpticUpdateTest {
+
+ static final String SETUP_XQUERY =
+ "xquery version '1.0-ml';" +
+ "let $jsondoc1 := object-node {'AllDataTypes': array-node {object-node {'word':'dog'}, object-node {'rank':1}, object-node {'score':4}}}" +
+ "let $jsondoc2 := object-node {'AllDataTypes': array-node {object-node {'word':'cat'}, object-node {'rank':2}, object-node {'score':5}}}" +
+ "let $jsondoc3 := object-node {'AllDataTypes': array-node {object-node {'word':'duck'}, object-node {'rank':3}, object-node {'score':6}}}" +
+ "return (" +
+ "xdmp:document-insert('range-prop-1.json', $jsondoc1, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
+ "xdmp:document-insert('range-prop-2.json', $jsondoc2, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
+ "xdmp:document-insert('range-prop-3.json', $jsondoc3, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
+ "xdmp:document-set-properties('range-prop-1.json', (opticfragmentpropvalue prop1value))," +
+ "xdmp:document-set-properties('range-prop-2.json', (opticfragmentpropvalue prop2value))," +
+ "xdmp:document-set-properties('range-prop-3.json', (opticfragmentpropvalue prop3value))," +
+ "xdmp:lock-acquire('range-prop-1.json', 'exclusive', '0', 'dog rose', xs:unsignedLong(120))," +
+ "xdmp:lock-acquire('range-prop-2.json', 'exclusive', '0', 'cat tulip', xs:unsignedLong(120))," +
+ "xdmp:lock-acquire('range-prop-3.json', 'exclusive', '0', 'duck lily', xs:unsignedLong(120))" +
+ ")";
+
+ static final String TEARDOWN_XQUERY =
+ "xquery version '1.0-ml';" +
+ "for $uri in ('range-prop-1.json', 'range-prop-2.json', 'range-prop-3.json') return xdmp:document-delete($uri)";
+
+ static final List EXPECTED_URIS = List.of(
+ "range-prop-1.json", "range-prop-2.json", "range-prop-3.json");
+
+ @BeforeEach
+ void setupTest() {
+ rowManager.withUpdate(false);
+ Common.newEvalClient().newServerEval().xquery(SETUP_XQUERY).evalAs(String.class);
+ }
+
+ @AfterEach
+ void teardownTest() {
+ Common.newEvalClient().newServerEval().xquery(TEARDOWN_XQUERY).evalAs(String.class);
+ }
+}
diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchDocsWithFragmentTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchDocsWithFragmentTest.java
index b58b61291..30e98b27e 100644
--- a/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchDocsWithFragmentTest.java
+++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchDocsWithFragmentTest.java
@@ -5,11 +5,8 @@
import com.marklogic.client.io.JacksonHandle;
import com.marklogic.client.row.RowRecord;
-import com.marklogic.client.test.Common;
import com.marklogic.client.test.junit5.RequiresML12Dot1;
import com.marklogic.client.type.PlanSearchOptions;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -30,42 +27,7 @@
* @see FromSearchWithFragmentTest for equivalent tests using {@code op.fromSearch()}.
*/
@ExtendWith(RequiresML12Dot1.class)
-class FromSearchDocsWithFragmentTest extends AbstractOpticUpdateTest {
-
- private static final String SETUP_XQUERY =
- "xquery version '1.0-ml';" +
- "let $jsondoc1 := object-node {'AllDataTypes': array-node {object-node {'word':'dog'}, object-node {'rank':1}, object-node {'score':4}}}" +
- "let $jsondoc2 := object-node {'AllDataTypes': array-node {object-node {'word':'cat'}, object-node {'rank':2}, object-node {'score':5}}}" +
- "let $jsondoc3 := object-node {'AllDataTypes': array-node {object-node {'word':'duck'}, object-node {'rank':3}, object-node {'score':6}}}" +
- "return (" +
- "xdmp:document-insert('range-prop-1.json', $jsondoc1, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
- "xdmp:document-insert('range-prop-2.json', $jsondoc2, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
- "xdmp:document-insert('range-prop-3.json', $jsondoc3, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
- "xdmp:document-set-properties('range-prop-1.json', (opticfragmentpropvalue prop1value))," +
- "xdmp:document-set-properties('range-prop-2.json', (opticfragmentpropvalue prop2value))," +
- "xdmp:document-set-properties('range-prop-3.json', (opticfragmentpropvalue prop3value))," +
- "xdmp:lock-acquire('range-prop-1.json', 'exclusive', '0', 'dog rose', xs:unsignedLong(120))," +
- "xdmp:lock-acquire('range-prop-2.json', 'exclusive', '0', 'cat tulip', xs:unsignedLong(120))," +
- "xdmp:lock-acquire('range-prop-3.json', 'exclusive', '0', 'duck lily', xs:unsignedLong(120))" +
- ")";
-
- private static final String TEARDOWN_XQUERY =
- "xquery version '1.0-ml';" +
- "for $uri in ('range-prop-1.json', 'range-prop-2.json', 'range-prop-3.json') return xdmp:document-delete($uri)";
-
- private static final List EXPECTED_URIS = List.of(
- "range-prop-1.json", "range-prop-2.json", "range-prop-3.json");
-
- @BeforeEach
- void setupTest() {
- rowManager.withUpdate(false);
- Common.newEvalClient().newServerEval().xquery(SETUP_XQUERY).evalAs(String.class);
- }
-
- @AfterEach
- void teardownTest() {
- Common.newEvalClient().newServerEval().xquery(TEARDOWN_XQUERY).evalAs(String.class);
- }
+class FromSearchDocsWithFragmentTest extends AbstractFromSearchFragmentTest {
/**
* Test case: Verifies the default {@code fromSearchDocs} behavior when no {@code fragment} option
diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchWithFragmentTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchWithFragmentTest.java
index ea3664e7b..39b4d310a 100644
--- a/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchWithFragmentTest.java
+++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchWithFragmentTest.java
@@ -5,11 +5,8 @@
import com.marklogic.client.io.JacksonHandle;
import com.marklogic.client.row.RowRecord;
-import com.marklogic.client.test.Common;
import com.marklogic.client.test.junit5.RequiresML12Dot1;
import com.marklogic.client.type.PlanSearchOptions;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -30,42 +27,7 @@
* @see FromSearchDocsWithFragmentTest for equivalent tests using {@code op.fromSearchDocs()}.
*/
@ExtendWith(RequiresML12Dot1.class)
-class FromSearchWithFragmentTest extends AbstractOpticUpdateTest {
-
- private static final String SETUP_XQUERY =
- "xquery version '1.0-ml';" +
- "let $jsondoc1 := object-node {'AllDataTypes': array-node {object-node {'word':'dog'}, object-node {'rank':1}, object-node {'score':4}}}" +
- "let $jsondoc2 := object-node {'AllDataTypes': array-node {object-node {'word':'cat'}, object-node {'rank':2}, object-node {'score':5}}}" +
- "let $jsondoc3 := object-node {'AllDataTypes': array-node {object-node {'word':'duck'}, object-node {'rank':3}, object-node {'score':6}}}" +
- "return (" +
- "xdmp:document-insert('range-prop-1.json', $jsondoc1, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
- "xdmp:document-insert('range-prop-2.json', $jsondoc2, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
- "xdmp:document-insert('range-prop-3.json', $jsondoc3, xdmp:default-permissions(), ('elemCol','jsondoc-range','from-search-fragment-test'))," +
- "xdmp:document-set-properties('range-prop-1.json', (opticfragmentpropvalue prop1value))," +
- "xdmp:document-set-properties('range-prop-2.json', (opticfragmentpropvalue prop2value))," +
- "xdmp:document-set-properties('range-prop-3.json', (opticfragmentpropvalue prop3value))," +
- "xdmp:lock-acquire('range-prop-1.json', 'exclusive', '0', 'dog rose', xs:unsignedLong(120))," +
- "xdmp:lock-acquire('range-prop-2.json', 'exclusive', '0', 'cat tulip', xs:unsignedLong(120))," +
- "xdmp:lock-acquire('range-prop-3.json', 'exclusive', '0', 'duck lily', xs:unsignedLong(120))" +
- ")";
-
- private static final String TEARDOWN_XQUERY =
- "xquery version '1.0-ml';" +
- "for $uri in ('range-prop-1.json', 'range-prop-2.json', 'range-prop-3.json') return xdmp:document-delete($uri)";
-
- private static final List EXPECTED_URIS = List.of(
- "range-prop-1.json", "range-prop-2.json", "range-prop-3.json");
-
- @BeforeEach
- void setupTest() {
- rowManager.withUpdate(false);
- Common.newEvalClient().newServerEval().xquery(SETUP_XQUERY).evalAs(String.class);
- }
-
- @AfterEach
- void teardownTest() {
- Common.newEvalClient().newServerEval().xquery(TEARDOWN_XQUERY).evalAs(String.class);
- }
+class FromSearchWithFragmentTest extends AbstractFromSearchFragmentTest {
/**
* Test case: Verifies the default {@code fromSearch} behavior when no {@code fragment} option