Skip to content

Commit d325837

Browse files
committed
MLE-28335 added fragment option in fromSearch
- Add 'fragment' option support to fromSearch() for MLS 12.1+ - Valid values: 'document' (default), 'properties', 'locks', 'any' - Client-side validation in PlanSearchOption (plan-builder-base.js) - Updated JSDoc for fromSearch() in plan-builder-generated.js - Added xdmp-lock-acquire/release privileges to rest-evaluator role in both test-setup-users.js and rest-evaluator.json (Gradle config) - Added fragment option integration tests to test-basic/plan-search.js (TC0-TC5, gated on serverVersion >= 12.1)
1 parent 16e8f7d commit d325837

5 files changed

Lines changed: 209 additions & 1 deletion

File tree

etc/test-setup-users.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ function setupUsers(manager, done) {
6868
'privilege-name': 'xdmp-get-session-field',
6969
action: 'http://marklogic.com/xdmp/privileges/xdmp-get-session-field',
7070
kind: 'execute'
71+
},
72+
{
73+
'privilege-name': 'xdmp-lock-acquire',
74+
action: 'http://marklogic.com/xdmp/privileges/xdmp-lock-acquire',
75+
kind: 'execute'
76+
},
77+
{
78+
'privilege-name': 'xdmp-lock-release',
79+
action: 'http://marklogic.com/xdmp/privileges/xdmp-lock-release',
80+
kind: 'execute'
7181
}
7282
]
7383
}

lib/plan-builder-base.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ function castArg(arg, funcName, paramName, argPos, paramTypes) {
136136
throw new Error(
137137
'bm25LengthWeight must be a number'
138138
);
139+
case 'fragment':
140+
if (['document', 'properties', 'locks', 'any'].includes(value)) {
141+
return true;
142+
}
143+
throw new Error(
144+
`${argLabel(funcName, paramName, argPos)} fragment can only be 'document', 'properties', 'locks', or 'any'`
145+
);
139146
default:
140147
return false;
141148
}});

lib/plan-builder-generated.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9012,7 +9012,7 @@ fromSearchDocs(...args) {
90129012
* @param { PlanSearchQuery } [query] - Qualifies and establishes the scores for a set of documents. The query can be a cts:query or a string as a shortcut for a cts:word-query. The fragments are not filtered to ensure they match the query, but instead selected in the same manner as "unfiltered" cts:search operations.
90139013
* @param { PlanExprColName } [columns] - Specifies which of the available columns to include in the rows. The available columns include the metrics for relevance ('confidence', 'fitness', 'quality', and 'score') and fragmentId for the document identifier. By default, the rows have the fragmentId and score columns. To rename a column, use op:as specifying the new name for an op:col with the old name.
90149014
* @param { XsString } [qualifierName] - Specifies a name for qualifying the column names.
9015-
* @param { PlanSearchOption } [option] - Similar to the options of cts:search, supplies the 'scoreMethod' key with a value of 'logtfidf', 'logtf', 'simple', 'zero', 'random', or 'bm25' to specify the method for assigning a score to matched documents or supplies the 'qualityWeight' key with a numeric value to specify a multiplier for the quality contribution to the score. Specify a value between 0 (exclusive) and 1 (inclusive) for bm25LengthWeight if 'bm25' scoring method is used.
9015+
* @param { PlanSearchOption } [option] - Similar to the options of cts:search, supplies the 'scoreMethod' key with a value of 'logtfidf', 'logtf', 'simple', 'zero', 'random', or 'bm25' to specify the method for assigning a score to matched documents or supplies the 'qualityWeight' key with a numeric value to specify a multiplier for the quality contribution to the score. Specify a value between 0 (exclusive) and 1 (inclusive) for bm25LengthWeight if 'bm25' scoring method is used. As of MLS 12.1, supplies the 'fragment' key with a value of 'document' (default), 'properties', 'locks', or 'any' to specify which document fragment type to search. Note: on servers earlier than MLS 12.1, the 'fragment' option is silently ignored and all fragment types are searched.
90169016
* @returns { planBuilder.AccessPlan }
90179017
*/
90189018
fromSearch(...args) {

test-app/src/main/ml-config/security/roles/rest-evaluator.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@
6565
"privilege-name": "xdmp-xslt-invoke",
6666
"action": "http://marklogic.com/xdmp/privileges/xslt-invoke",
6767
"kind": "execute"
68+
},
69+
{
70+
"privilege-name": "xdmp-lock-acquire",
71+
"action": "http://marklogic.com/xdmp/privileges/xdmp-lock-acquire",
72+
"kind": "execute"
73+
},
74+
{
75+
"privilege-name": "xdmp-lock-release",
76+
"action": "http://marklogic.com/xdmp/privileges/xdmp-lock-release",
77+
"kind": "execute"
6878
}
6979
]
7080
}

test-basic/plan-search.js

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,4 +308,185 @@ describe('search', function() {
308308
}).catch(error => done(error));
309309
});
310310
});
311+
312+
describe('fragment option tests for fromSearch', function() {
313+
const setupXquery = `
314+
xquery version "1.0-ml";
315+
let $jsondoc1 := object-node {"AllDataTypes": array-node {object-node {"word":"dog"}, object-node {"rank":1}, object-node {"score":4}}}
316+
let $jsondoc2 := object-node {"AllDataTypes": array-node {object-node {"word":"cat"}, object-node {"rank":2}, object-node {"score":5}}}
317+
let $jsondoc3 := object-node {"AllDataTypes": array-node {object-node {"word":"duck"}, object-node {"rank":3}, object-node {"score":6}}}
318+
return (
319+
xdmp:document-insert("range-prop-1.json", $jsondoc1, xdmp:default-permissions(), ("elemCol","jsondoc-range")),
320+
xdmp:document-insert("range-prop-2.json", $jsondoc2, xdmp:default-permissions(), ("elemCol","jsondoc-range")),
321+
xdmp:document-insert("range-prop-3.json", $jsondoc3, xdmp:default-permissions(), ("elemCol","jsondoc-range")),
322+
xdmp:document-set-properties("range-prop-1.json", (<my-prop>opticfragmentpropvalue</my-prop>)),
323+
xdmp:lock-acquire("range-prop-1.json", "exclusive", "0", "dog rose", xs:unsignedLong(120)),
324+
xdmp:lock-acquire("range-prop-2.json", "exclusive", "0", "cat tulip", xs:unsignedLong(120)),
325+
xdmp:lock-acquire("range-prop-3.json", "exclusive", "0", "duck lily", xs:unsignedLong(120))
326+
)
327+
`;
328+
329+
const teardownReleaseLocks = `
330+
xquery version "1.0-ml";
331+
(
332+
xdmp:lock-release("range-prop-1.json"),
333+
xdmp:lock-release("range-prop-2.json"),
334+
xdmp:lock-release("range-prop-3.json")
335+
)
336+
`;
337+
338+
const teardownDeleteDocs = `
339+
xquery version "1.0-ml";
340+
(
341+
xdmp:document-delete("range-prop-1.json"),
342+
xdmp:document-delete("range-prop-2.json"),
343+
xdmp:document-delete("range-prop-3.json")
344+
)
345+
`;
346+
347+
before(function(done) {
348+
if (serverConfiguration.serverVersion < 12.1) {
349+
this.skip();
350+
}
351+
pbb.dbWriter.xqueryEval(setupXquery).result()
352+
.then(() => done())
353+
.catch(done);
354+
});
355+
356+
after(function(done) {
357+
pbb.dbWriter.xqueryEval(teardownReleaseLocks).result()
358+
.then(() => pbb.dbWriter.xqueryEval(teardownDeleteDocs).result())
359+
.then(() => done())
360+
.catch(done);
361+
});
362+
363+
// TC0: No fragment option — default behavior searches document content (same as fragment:'document')
364+
it('TC0: fromSearch without fragment option should search document content by default', function(done) {
365+
execPlan(
366+
p.fromSearch(
367+
p.cts.wordQuery('dog')
368+
)
369+
.joinDocAndUri('doc', 'uri', p.fragmentIdCol('fragmentId'))
370+
.orderBy('uri')
371+
.select(['uri', 'doc'])
372+
).then(function(response) {
373+
const output = getResults(response);
374+
assert(output.length === 1, 'Expected exactly 1 document containing "dog" with default fragment');
375+
assert(output[0].uri.value === 'range-prop-1.json', 'Expected range-prop-1.json');
376+
assert(output[0].doc.type === 'object', 'Expected default fragment to return JSON document');
377+
assert(output[0].doc.value.AllDataTypes[0].word === 'dog', 'Expected word "dog" in document content');
378+
done();
379+
}).catch(done);
380+
});
381+
382+
// TC0b: Invalid fragment value → client-side error (no server call needed)
383+
it('TC0b: should throw error for invalid fragment value', function() {
384+
assert.throws(function() {
385+
p.fromSearch(
386+
p.cts.wordQuery('dog'), null, null, { fragment: 'unknown' }
387+
);
388+
}, /fragment can only be/);
389+
});
390+
391+
// TC1: fragment:'locks' — doc joined from locks fragment must be XML containing 'lock-type'
392+
it('TC1: fromSearch with fragment:locks should find documents by lock token', function(done) {
393+
execPlan(
394+
p.fromSearch(
395+
p.cts.locksFragmentQuery(p.cts.wordQuery('dog')),
396+
null, null, { fragment: 'locks' }
397+
)
398+
.joinDocAndUri('doc', 'uri', p.fragmentIdCol('fragmentId'))
399+
.orderBy('uri')
400+
.select(['uri', 'doc'])
401+
).then(function(response) {
402+
const output = getResults(response);
403+
assert(output.length === 1, 'Expected exactly 1 result from locks fragment');
404+
assert(output[0].uri.value === 'range-prop-1.json', 'Expected range-prop-1.json');
405+
assert(output[0].doc.type === 'element', 'Expected lock doc to be XML element');
406+
assert(output[0].doc.value.includes('lock-type'), 'Expected lock-type element in lock document');
407+
done();
408+
}).catch(done);
409+
});
410+
411+
// TC2: fragment:'properties' — doc joined from properties fragment must be XML containing the property value
412+
it('TC2: fromSearch with fragment:properties should find doc by its properties', function(done) {
413+
execPlan(
414+
p.fromSearch(
415+
p.cts.wordQuery('opticfragmentpropvalue'),
416+
null, null, { fragment: 'properties' }
417+
)
418+
.joinDocAndUri('doc', 'uri', p.fragmentIdCol('fragmentId'))
419+
.orderBy('uri')
420+
.select(['uri', 'doc'])
421+
).then(function(response) {
422+
const output = getResults(response);
423+
assert(output.length === 1, 'Expected exactly 1 result from properties fragment');
424+
assert(output[0].uri.value === 'range-prop-1.json', 'Expected range-prop-1.json');
425+
assert(output[0].doc.type === 'element', 'Expected properties doc to be XML element');
426+
assert(output[0].doc.value.includes('opticfragmentpropvalue'), 'Expected property value in properties document');
427+
done();
428+
}).catch(done);
429+
});
430+
431+
// TC3: fragment:'any' — returns all fragment types; verify both XML (lock/properties) and JSON (document) rows present
432+
it('TC3: fromSearch with fragment:any should return results across fragment types', function(done) {
433+
execPlan(
434+
p.fromSearch(
435+
p.cts.locksFragmentQuery(p.cts.wordQuery('dog')),
436+
null, null, { fragment: 'any' }
437+
)
438+
.joinDocAndUri('doc', 'uri', p.fragmentIdCol('fragmentId'))
439+
.orderBy('uri')
440+
.select(['uri', 'doc'])
441+
).then(function(response) {
442+
const output = getResults(response);
443+
assert(output.length > 1, 'Expected multiple rows (all fragment types) with fragment:any');
444+
const types = output.map(row => row.doc.type);
445+
assert(types.includes('element'), 'Expected at least one XML fragment (lock or properties)');
446+
assert(types.includes('object'), 'Expected at least one JSON document fragment');
447+
done();
448+
}).catch(done);
449+
});
450+
451+
// TC4: fragment:'document' — doc must be JSON containing the word 'dog'
452+
it('TC4: fromSearch with fragment:document should find documents by content word', function(done) {
453+
execPlan(
454+
p.fromSearch(
455+
p.cts.wordQuery('dog'),
456+
null, null, { fragment: 'document' }
457+
)
458+
.joinDocAndUri('doc', 'uri', p.fragmentIdCol('fragmentId'))
459+
.orderBy('uri')
460+
.select(['uri', 'doc'])
461+
).then(function(response) {
462+
const output = getResults(response);
463+
assert(output.length === 1, 'Expected exactly 1 document containing "dog"');
464+
assert(output[0].uri.value === 'range-prop-1.json', 'Expected range-prop-1.json');
465+
assert(output[0].doc.type === 'object', 'Expected document fragment to be JSON');
466+
assert(output[0].doc.value.AllDataTypes[0].word === 'dog', 'Expected word "dog" in document content');
467+
done();
468+
}).catch(done);
469+
});
470+
471+
// TC5: explain() on a locks fragment plan should return a valid execution plan structure.
472+
// Note: the server-side equivalent (TEST26) additionally exercises plan:parse()/plan:execute()
473+
// on the explain output, but the Node client has no equivalent of those functions.
474+
it('TC5: explain() on a locks fragment plan should return a valid plan structure', function(done) {
475+
const plan = p.fromSearch(
476+
p.cts.locksFragmentQuery(p.cts.wordQuery('dog')),
477+
null, null, { fragment: 'locks' }
478+
)
479+
.joinDocAndUri('doc', 'uri', p.fragmentIdCol('fragmentId'))
480+
.orderBy('uri')
481+
.select(['uri', 'doc']);
482+
483+
pbb.explainPlan(plan)
484+
.then(function(output) {
485+
assert(output.node === 'plan', 'Expected explain output to have node:"plan"');
486+
assert(output.expr != null, 'Expected expr to be present in explain output');
487+
done();
488+
})
489+
.catch(done);
490+
});
491+
});
311492
});

0 commit comments

Comments
 (0)