From 8472f90212224cc92e0f4279423c4c0f919c44e1 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 14 May 2026 15:07:12 -0400 Subject: [PATCH] MLE-28334 Implement Fragment Option --- .../client/expression/PlanBuilderBase.java | 23 ++- .../client/impl/PlanBuilderBaseImpl.java | 89 ++++---- .../client/impl/PlanBuilderSubImpl.java | 4 +- .../client/type/PlanSearchOptions.java | 38 +++- .../client/test/junit5/RequiresML12Dot1.java | 28 +++ .../rows/FromSearchDocsWithFragmentTest.java | 194 ++++++++++++++++++ .../test/rows/FromSearchWithFragmentTest.java | 189 +++++++++++++++++ 7 files changed, 516 insertions(+), 49 deletions(-) create mode 100644 marklogic-client-api/src/test/java/com/marklogic/client/test/junit5/RequiresML12Dot1.java create mode 100644 marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchDocsWithFragmentTest.java create mode 100644 marklogic-client-api/src/test/java/com/marklogic/client/test/rows/FromSearchWithFragmentTest.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 669730712..d7bb3f62b 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 @@ -43,17 +43,24 @@ public interface PlanBuilderBase { */ PlanBuilder.AccessPlan fromSearchDocs(CtsQueryExpr query, String qualifierName); /** - * Provides a convenience for matching documents and constructing rows with the score, - * document URI, and document content. The convenience is equivalent to chaining + * Matches documents and constructs rows with the score, document URI, and document content, + * with control over which fragment types are searched via {@link PlanSearchOptions#withFragment(PlanSearchOptions.Fragment)}. + *

When no {@code fragment} option is set, behavior is equivalent to chaining * {@link PlanBuilder#fromSearch(CtsQueryExpr)}, * {@link PlanBuilder.ModifyPlan#joinDocUri(String, String)}, * and {@link PlanBuilder.ModifyPlan#joinDoc(String, String)}. - *

The documents can be ordered by the score and limited for the most relevant - * documents.

+ * When a non-default fragment type such as {@link PlanSearchOptions.Fragment#LOCKS} or + * {@link PlanSearchOptions.Fragment#PROPERTIES} is specified, the search targets those fragment + * types and the returned {@code uri} column resolves to the URI of the associated document.

+ *

The documents can be ordered by the score and limited for the most relevant documents.

* @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. - * @return a ModifyPlan object - * @since 7.0.0; requires MarkLogic 12 or higher. + * @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. + * @return an AccessPlan object + * @since 8.2.0; requires MarkLogic 12.1 or higher. */ PlanBuilder.AccessPlan fromSearchDocs(CtsQueryExpr query, String qualifierName, PlanSearchOptions options); /** @@ -131,7 +138,9 @@ public interface PlanBuilderBase { * @param query The cts.query expression for matching the documents. * @param columns The columns to project for the documents. See {@link PlanBuilder#colSeq(String...)} * @param qualifierName Specifies a name for qualifying the column names similar to a view name. - * @param options Specifies how to calculate the score for the matching documents. See {@link PlanBuilder#searchOptions()} + * @param options Specifies how to calculate the score for the matching documents and which fragment + * types to return. Use {@link PlanBuilder#searchOptions()} with + * {@link PlanSearchOptions#withFragment(PlanSearchOptions.Fragment)} to control the fragment scope. * @return an AccessPlan object */ PlanBuilder.AccessPlan fromSearch(CtsQueryExpr query, PlanExprColSeq columns, XsStringVal qualifierName, PlanSearchOptions options); diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/PlanBuilderBaseImpl.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/PlanBuilderBaseImpl.java index 7c2e624cb..3d307c9ef 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/PlanBuilderBaseImpl.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/PlanBuilderBaseImpl.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; @@ -51,17 +51,20 @@ static class PlanSearchOptionsImpl implements PlanSearchOptions { private PlanBuilderBaseImpl pb; private XsFloatVal qualityWeight; private ScoreMethod scoreMethod; - private XsDoubleVal bm25LengthWeight; + private XsDoubleVal bm25LengthWeight; + private Fragment fragment; PlanSearchOptionsImpl(PlanBuilderBaseImpl pb) { this.pb = pb; } - PlanSearchOptionsImpl(PlanBuilderBaseImpl pb, XsFloatVal qualityWeight, - ScoreMethod scoreMethod, XsDoubleVal bm25LengthWeight) { - this(pb); - this.qualityWeight = qualityWeight; - this.scoreMethod = scoreMethod; - this.bm25LengthWeight = bm25LengthWeight; - } + PlanSearchOptionsImpl(PlanBuilderBaseImpl pb, XsFloatVal qualityWeight, + ScoreMethod scoreMethod, XsDoubleVal bm25LengthWeight, + Fragment fragment) { + this(pb); + this.qualityWeight = qualityWeight; + this.scoreMethod = scoreMethod; + this.bm25LengthWeight = bm25LengthWeight; + this.fragment = fragment; + } @Override public XsFloatVal getQualityWeight() { @@ -71,43 +74,55 @@ public XsFloatVal getQualityWeight() { public ScoreMethod getScoreMethod() { return scoreMethod; } - @Override - public XsDoubleVal getBm25LengthWeight() { - return bm25LengthWeight; - } + @Override + public XsDoubleVal getBm25LengthWeight() { + return bm25LengthWeight; + } + @Override + public Fragment getFragment() { + return fragment; + } @Override public PlanSearchOptions withQualityWeight(float qualityWeight) { return withQualityWeight(pb.xs.floatVal(qualityWeight)); } @Override public PlanSearchOptions withQualityWeight(XsFloatVal qualityWeight) { - return new PlanSearchOptionsImpl(pb, qualityWeight, getScoreMethod(), getBm25LengthWeight()); + return new PlanSearchOptionsImpl(pb, qualityWeight, getScoreMethod(), getBm25LengthWeight(), getFragment()); } @Override public PlanSearchOptions withScoreMethod(ScoreMethod scoreMethod) { - return new PlanSearchOptionsImpl(pb, getQualityWeight(), scoreMethod, getBm25LengthWeight()); - } - - @Override - public PlanSearchOptions withBm25LengthWeight(double bm25LengthWeight) { - return new PlanSearchOptionsImpl(pb, getQualityWeight(), getScoreMethod(), pb.xs.doubleVal(bm25LengthWeight)); - } - - Map makeMap() { - if (qualityWeight == null && scoreMethod == null && bm25LengthWeight == null) return null; - - Map map = new HashMap<>(); - if (qualityWeight != null) { - map.put("qualityWeight", qualityWeight); - } - if (scoreMethod != null) { - map.put("scoreMethod", scoreMethod.name().toLowerCase()); - } - if (bm25LengthWeight != null) { - map.put("bm25LengthWeight", bm25LengthWeight); - } - return map; - } + return new PlanSearchOptionsImpl(pb, getQualityWeight(), scoreMethod, getBm25LengthWeight(), getFragment()); + } + + @Override + public PlanSearchOptions withBm25LengthWeight(double bm25LengthWeight) { + return new PlanSearchOptionsImpl(pb, getQualityWeight(), getScoreMethod(), pb.xs.doubleVal(bm25LengthWeight), getFragment()); + } + + @Override + public PlanSearchOptions withFragment(Fragment fragment) { + return new PlanSearchOptionsImpl(pb, getQualityWeight(), getScoreMethod(), getBm25LengthWeight(), fragment); + } + + Map makeMap() { + if (qualityWeight == null && scoreMethod == null && bm25LengthWeight == null && fragment == null) return null; + + Map map = new HashMap<>(); + if (qualityWeight != null) { + map.put("qualityWeight", qualityWeight); + } + if (scoreMethod != null) { + map.put("scoreMethod", scoreMethod.name().toLowerCase()); + } + if (bm25LengthWeight != null) { + map.put("bm25LengthWeight", bm25LengthWeight); + } + if (fragment != null) { + map.put("fragment", fragment.name().toLowerCase()); + } + return map; + } } static class PlanParamBase extends BaseTypeImpl.BaseCallImpl 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"); + } +}