diff --git a/lib/plan-builder-base.js b/lib/plan-builder-base.js index c983e446..1ab6f226 100644 --- a/lib/plan-builder-base.js +++ b/lib/plan-builder-base.js @@ -64,6 +64,13 @@ function castArg(arg, funcName, paramName, argPos, paramTypes) { if(arg._ns === 'vec'){ return arg; } + // cts.param() is a valid placeholder wherever a cts.query is accepted (e.g. as a direct + // sub-query argument to cts.orQuery, cts.andQuery, cts.notQuery, etc.). + // Serialisation already works: exportObject converts _ns/_fn/_args -> ns/fn/args. + if (arg._ns === 'cts' && arg._fn === 'param' && + paramTypes.includes(types.CtsQuery)) { + return arg; + } throw new Error( `${argLabel(funcName, paramName, argPos)} must have type ${typeLabel(paramTypes)}` ); diff --git a/lib/plan-builder-generated.js b/lib/plan-builder-generated.js index 04f38879..15fae429 100755 --- a/lib/plan-builder-generated.js +++ b/lib/plan-builder-generated.js @@ -7420,12 +7420,12 @@ class PlanPlan extends types.ServerType { * @method planBuilder.Plan#bindParam * @since 2.1.1 * @param { PlanParamName } [param] - - * @param { PlanParamBinding } [literal] - + * @param { PlanParamBinding|CtsQuery } [literal] - * @returns { planBuilder.Plan } */ bindParam(...args) { const namer = bldrbase.getNamer(args, 'param'); - const paramdefs = [['param', [PlanParam, types.XsString], true, false], ['literal', [PlanParamBinding], true, false]]; + const paramdefs = [['param', [PlanParam, types.XsString], true, false], ['literal', [PlanParamBinding, types.CtsQuery], true, false]]; const checkedArgs = (namer !== null) ? bldrbase.makeNamedArgs(namer, 'PlanPlan.bindParam', 2, new Set(['param', 'literal']), paramdefs, args) : bldrbase.makePositionalArgs('PlanPlan.bindParam', 2, false, paramdefs, args); @@ -8114,7 +8114,7 @@ union(...args) { * @returns { planBuilder.ModifyPlan } */ where(...args) { - const paramdef = ['condition', [types.XsBoolean, PlanColumn, types.CtsQuery, types.SemStore, PlanCondition], true, false]; + const paramdef = ['condition', [types.XsBoolean, PlanColumn, types.CtsQuery, types.SemStore, PlanCondition, PlanParam], true, false]; const checkedArgs = bldrbase.makeSingleArgs('PlanModifyPlan.where', 1, paramdef, args); return new PlanModifyPlan(this, 'op', 'where', checkedArgs); } @@ -8998,7 +8998,7 @@ fromSQL(...args) { */ fromSearchDocs(...args) { const namer = bldrbase.getNamer(args, 'query'); - const paramdefs = [['query', [types.XsString, types.CtsQuery], true, false], ['qualifierName', [types.XsString], false, false], ['option', [PlanSearchOption], false, false]]; + const paramdefs = [['query', [types.XsString, types.CtsQuery, PlanParam], true, false], ['qualifierName', [types.XsString], false, false], ['option', [PlanSearchOption], false, false]]; const checkedArgs = (namer !== null) ? bldrbase.makeNamedArgs(namer, 'PlanBuilder.fromSearchDocs', 1, new Set(['query', 'qualifierName', 'option']), paramdefs, args) : bldrbase.makePositionalArgs('PlanBuilder.fromSearchDocs', 1, false, paramdefs, args); @@ -9017,7 +9017,7 @@ fromSearchDocs(...args) { */ fromSearch(...args) { const namer = bldrbase.getNamer(args, 'query'); - const paramdefs = [['query', [types.XsString, types.CtsQuery], true, false], ['columns', [PlanExprCol, PlanColumn, types.XsString], false, true], ['qualifierName', [types.XsString], false, false], ['option', [PlanSearchOption], false, false]]; + const paramdefs = [['query', [types.XsString, types.CtsQuery, PlanParam], true, false], ['columns', [PlanExprCol, PlanColumn, types.XsString], false, true], ['qualifierName', [types.XsString], false, false], ['option', [PlanSearchOption], false, false]]; const checkedArgs = (namer !== null) ? bldrbase.makeNamedArgs(namer, 'PlanBuilder.fromSearch', 1, new Set(['query', 'columns', 'qualifierName', 'option']), paramdefs, args) : bldrbase.makePositionalArgs('PlanBuilder.fromSearch', 1, false, paramdefs, args); diff --git a/lib/requester.js b/lib/requester.js index 63bd1479..78b211f0 100644 --- a/lib/requester.js +++ b/lib/requester.js @@ -1,5 +1,5 @@ /* -* Copyright (c) 2015-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. +* Copyright (c) 2015-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ 'use strict'; const createAuthInitializer = require('./www-authenticate-patched/www-authenticate'); @@ -400,7 +400,7 @@ function multipartRequester(request) { form.setBoundary(mlutil.multipartBoundary); form.append('query', query, {contentType: 'application/json', filename: 'fromParam-AST.js'}); - form.append(key, JSON.stringify(binding), {contentType: 'application/json', filename: 'data.json'}); + form.append(key, JSON.stringify(binding), {contentType: 'application/json', filename: 'data.json'}); if(attachments && attachments instanceof Array && attachments.length) { for (let i = 0; i < attachments.length; i++) { diff --git a/lib/rows.js b/lib/rows.js index f24b72b2..d732e2f0 100644 --- a/lib/rows.js +++ b/lib/rows.js @@ -1,5 +1,5 @@ /* -* Copyright (c) 2015-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. +* Copyright (c) 2015-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ 'use strict'; @@ -8,6 +8,7 @@ const requester = require('./requester.js'), mlutil = require('./mlutil.js'), Operation = require('./operation.js'), planBuilder = require('./plan-builder.js'); +const bldrbase = require('./plan-builder-base.js'); const stream = require('stream'); const bigInt = require('big-integer'); @@ -63,6 +64,42 @@ function Rows(client) { * binding. * @returns {Promise} A Promise. */ +// Walk an exported plan JSON node and replace every cts:param / op:param +// reference whose name appears in the replacements map with the mapped value. +// A single traversal handles all bindings, avoiding O(planSize × bindings) cost. +function substitutePlanParams(node, replacements) { + if (node === null || typeof node !== 'object') { return node; } + if (Array.isArray(node)) { + return node.map(item => substitutePlanParams(item, replacements)); + } + if ((node.ns === 'cts' || node.ns === 'op') && node.fn === 'param' && + Array.isArray(node.args) && Object.prototype.hasOwnProperty.call(replacements, node.args[0])) { + return replacements[node.args[0]]; + } + const result = {}; + for (const key of Object.keys(node)) { + result[key] = substitutePlanParams(node[key], replacements); + } + return result; +} + +// Partitions bindings into plan-builder node entries (exported {ns,fn,args[]} shapes) +// collected into planNodeMap, and the remainder for normal query-param binding. +function partitionExportedBindings(bindings) { + const planNodeMap = {}; + const remaining = {}; + for (const [k, v] of Object.entries(bindings)) { + const exported = (v !== null && typeof v === 'object') ? bldrbase.exportArg(v) : null; + if (exported && typeof exported === 'object' && typeof exported.ns === 'string' && + typeof exported.fn === 'string' && Array.isArray(exported.args)) { + planNodeMap[k] = exported; + } else { + remaining[k] = v; + } + } + return { planNodeMap, remaining }; +} + Rows.prototype.query = function queryRows() { const args = mlutil.asArray.apply(null, arguments); @@ -149,10 +186,39 @@ function queryRowsOperationImpl(self, builtPlan, streamType, options, bindingArg } } + // Substitute plan-builder bindings (CTS queries etc.) from both + // options.bindings and bindingArg into the exported plan JSON before any + // network serialisation. This lets callers pass CtsQuery objects via either + // the second-arg (options.bindings) or the third-arg (bindingArg) form. + let substitutedOptionBindings = options.bindings || null; + if (builtPlan instanceof planBuilder.Plan && (substitutedOptionBindings || bindingArg)) { + const replacements = {}; + + if (substitutedOptionBindings) { + const { planNodeMap, remaining } = partitionExportedBindings(substitutedOptionBindings); + if (Object.keys(planNodeMap).length > 0) { + Object.assign(replacements, planNodeMap); + substitutedOptionBindings = Object.keys(remaining).length > 0 ? remaining : null; + } + } + + if (bindingArg) { + const { planNodeMap, remaining } = partitionExportedBindings(bindingArg); + if (Object.keys(planNodeMap).length > 0) { + Object.assign(replacements, planNodeMap); + bindingArg = Object.keys(remaining).length > 0 ? remaining : null; + } + } + + if (Object.keys(replacements).length > 0) { + builtPlan = JSON.stringify(substitutePlanParams(builtPlan.export(), replacements)); + } + } + const contentTypeHeader = graphqlQuery ? 'application/graphql' : queryContentType(builtPlan, options.queryType); let endpoint = createRowsEndpoint(options, graphqlQuery, structure); - if (options.bindings) { - endpoint += mlutil.makeBindingsParams(options.bindings, sep); + if (substitutedOptionBindings) { + endpoint += mlutil.makeBindingsParams(substitutedOptionBindings, sep); if (sep === '?') { sep = '&'; } } @@ -163,7 +229,7 @@ function queryRowsOperationImpl(self, builtPlan, streamType, options, bindingArg const bindingKey = keys[0]; const attachments = keys[1]; const metadata = keys[2]; - const query = JSON.stringify(builtPlan.export()); + const query = typeof builtPlan === 'string' ? builtPlan : JSON.stringify(builtPlan.export()); const multipartBoundary = mlutil.multipartBoundary; const endpoint = createRowsEndpoint(options,graphqlQuery, structure); const requestOptions = mlutil.newRequestOptions(connectionParams, endpoint, 'POST'); @@ -181,8 +247,14 @@ function queryRowsOperationImpl(self, builtPlan, streamType, options, bindingArg 'Content-Type': 'multipart/form-data; boundary=' + multipartBoundary, }; + const rawBindingVal = bindingArg[bindingKey]; + const parsedBindingVal = (typeof rawBindingVal === 'string' && + rawBindingVal.length > 0 && + (rawBindingVal[0] === '{' || rawBindingVal[0] === '[')) + ? JSON.parse(rawBindingVal) + : rawBindingVal; operation.bindingParam = { - [bindingKey]: (typeof bindingArg[bindingKey] === 'string')?JSON.parse(bindingArg[bindingKey]):bindingArg[bindingKey], + [bindingKey]: parsedBindingVal, query: query, key: bindingKey, attachments: bindingArg[attachments], diff --git a/test-basic/optic-cts-param-test.js b/test-basic/optic-cts-param-test.js index 298fa1a3..31619423 100644 --- a/test-basic/optic-cts-param-test.js +++ b/test-basic/optic-cts-param-test.js @@ -3,7 +3,7 @@ */ /** - * Integration test for cts.param support in Optic plan builder (MLE-27883). + * Integration test for cts.param support in Optic plan builder. * * This test requires a running MarkLogic server with: * - Documents in the /optic/test collection @@ -15,7 +15,6 @@ const should = require('should'); const valcheck = require('core-util-is'); - const testconfig = require('../etc/test-config.js'); const marklogic = require('../'); const testlib = require('../etc/test-lib'); @@ -24,7 +23,33 @@ let serverConfiguration = {}; const db = marklogic.createDatabaseClient(testconfig.restWriterConnection); const op = marklogic.planBuilder; -describe('cts.param integration tests (MLE-27883)', function() { +function assertTrumpetMusiciansRows(response) { + const rows = response.rows; + rows.length.should.be.above(0); + const uris = rows.map(row => row['uri'].value || row['uri']); + uris.should.containEql('/optic/test/musician1.json'); + uris.should.containEql('/optic/test/musician4.json'); + uris.should.not.containEql('/optic/test/musician2.json'); + uris.should.not.containEql('/optic/test/musician3.json'); + rows.forEach(row => { + row.should.have.property('uri'); + row.should.have.property('doc'); + const docContent = JSON.stringify(row['doc'].value || row['doc']); + docContent.toLowerCase().should.containEql('trumpet'); + }); +} + +function assertAllMusiciansRows(response) { + const rows = response.rows; + rows.length.should.be.above(0); + const uris = rows.map(row => row['uri'].value || row['uri']); + uris.should.containEql('/optic/test/musician1.json'); + uris.should.containEql('/optic/test/musician2.json'); + uris.should.containEql('/optic/test/musician3.json'); + uris.should.containEql('/optic/test/musician4.json'); +} + +describe('cts.param integration tests', function() { this.timeout(10000); // Allow 10 seconds for server queries before(function(done) { @@ -43,6 +68,8 @@ describe('cts.param integration tests (MLE-27883)', function() { }); // ────────────────────────────────────────────────────────────────────────────── + // MLE-27883 op.cts.param() support in Optic plan builder + // // Test: collectionQuery with cts.param binding // ────────────────────────────────────────────────────────────────────────────── @@ -188,6 +215,22 @@ describe('cts.param integration tests (MLE-27883)', function() { should(serialized).containEql('"param"'); }); + it('should return musician1 and musician4 (trumpet) but not musician2 or musician3 for wordQuery with cts.param', function() { + const plan = op.fromSearchDocs(op.cts.wordQuery(op.cts.param('searchWord'))) + .select(['uri', 'doc']); + + return db.rows.query(plan, { + bindings: { + searchWord: { value: 'trumpet', type: 'string' } + } + }) + .then(function(response) { + should.exist(response, 'response should exist'); + response.should.have.property('rows'); + assertTrumpetMusiciansRows(response); + }); + }); + }); // ────────────────────────────────────────────────────────────────────────────── @@ -248,4 +291,221 @@ describe('cts.param integration tests (MLE-27883)', function() { }); + // ────────────────────────────────────────────────────────────────────────────── + // MLE-27889 Param binding support for CTS queries in Optic plans + // + // cts.param() as direct sub-query of composite CTS functions + // ────────────────────────────────────────────────────────────────────────────── + + describe('cts.param as direct child of orQuery', function() { + + it('orQuery with literal string is accepted by the server', function() { + const plan = op + .fromSearchDocs(op.cts.orQuery([ + 'saxophone', + 'trumpet' + ])) + .select(['uri']); + + return db.rows.query(plan) + .then(function(response) { + if (response && response.rows) { assertAllMusiciansRows(response); } + }); + }); + + it('orQuery with literal string and cts.param bound to string is accepted by the server', function() { + const plan = op + .fromSearchDocs(op.cts.orQuery([ + 'saxophone', + op.cts.param('searchWord') + ])) + .select(['uri']); + + return db.rows.query(plan, { + bindings: { + searchWord: { value: 'trumpet', type: 'string' } + } + }) + .then(function(response) { + if (response && response.rows) { assertAllMusiciansRows(response); } + }); + }); + + it('orQuery with wordQuery and cts.param bound to string is accepted by the server', function() { + const plan = op + .fromSearchDocs(op.cts.orQuery([ + op.cts.wordQuery('saxophone'), + op.cts.param('searchWord') + ])) + .select(['uri']); + + return db.rows.query(plan, { + bindings: { + searchWord: { value: 'trumpet', type: 'string' } + } + }) + .then(function(response) { + if (response && response.rows) { assertAllMusiciansRows(response); } + }); + }); + + it('orQuery with wordQuery and cts.param bound to CtsQuery via options.bindings', function() { + const plan = op + .fromSearchDocs(op.cts.orQuery([ + op.cts.wordQuery('saxophone'), + op.cts.param('query') + ])) + .select(['uri']); + + return db.rows.query(plan, { bindings: { query: op.cts.wordQuery('trumpet') } }) + .then(function(response) { + if (response && response.rows) { assertAllMusiciansRows(response); } + }); + }); + + }); + + // ────────────────────────────────────────────────────────────────────────────── + // Param binding with CtsQuery literal + // ────────────────────────────────────────────────────────────────────────────── + + describe('CtsQuery binding via options.bindings (second arg)', function() { + + it('fromSearchDocs(op.param("q")) with CtsQuery via options.bindings is accepted by the server', function() { + // Tests the options.bindings (second-arg) form of CTS-query binding. + // rows.js detects the plan-builder object in options.bindings and substitutes it + // into the exported plan JSON via substitutePlanParam before sending to the server. + const plan = op + .fromSearchDocs(op.param('q')) + .select(['uri', 'doc']); + + return db.rows.query(plan, { bindings: { q: op.cts.wordQuery('trumpet') } }) + .then(function(response) { + if (response && response.rows) { assertTrumpetMusiciansRows(response); } + }); + }); + + it('fromSearchDocs(op.cts.param("q")) with CtsQuery via options.bindings is accepted by the server', function() { + const plan = op + .fromSearchDocs(op.cts.param('q')) + .select(['uri', 'doc']); + + return db.rows.query(plan, { bindings: { q: op.cts.wordQuery('trumpet') } }) + .then(function(response) { + if (response && response.rows) { assertTrumpetMusiciansRows(response); } + }); + }); + + it('fromSearchDocs.where(op.cts.param("query")) with CtsQuery via options.bindings is accepted by the server', function() { + const plan = op + .fromSearchDocs(op.cts.wordQuery(['saxophone', 'trumpet'])) + .where(op.cts.param('query')) + .select(['uri', 'doc']); + + return db.rows.query(plan, { bindings: { query: op.cts.wordQuery('trumpet') } }) + .then(function(response) { + if (response && response.rows) { assertTrumpetMusiciansRows(response); } + }); + }); + + it('fromSearchDocs(wordQuery with cts.param string) with where(cts.param CtsQuery) via options.bindings returns trumpet musicians', function() { + const plan = op + .fromSearchDocs(op.cts.wordQuery(['saxophone', op.cts.param('searchWord')])) + .where(op.cts.param('query')) + .select(['uri', 'doc']); + + return db.rows.query(plan, { + bindings: { + searchWord: 'trumpet', + query: op.cts.wordQuery('trumpet') + } + }) + .then(function(response) { + if (response && response.rows) { assertTrumpetMusiciansRows(response); } + }); + }); + + }); + + // ────────────────────────────────────────────────────────────────────────────── + // op.param() bound to CtsQuery at runtime via bindingArg (third arg) + // + // The Node.js pattern: pass the CTS query as the third arg of db.rows.query. + // rows.js intercepts plan-builder objects in the third arg and substitutes them + // into the exported plan JSON via substitutePlanParam before sending, so the server sees them as plan literals. + // ────────────────────────────────────────────────────────────────────────────── + + describe('op.param() bound to CtsQuery at runtime via bindingArg (third arg)', function() { + + it('fromSearchDocs(op.param("q")) with CtsQuery binding is accepted by the server', function() { + const plan = op + .fromSearchDocs(op.param('q')) + .select(['uri', 'doc']); + + return db.rows.query(plan, null, { q: op.cts.wordQuery('trumpet') }) + .then(function(response) { + if (response && response.rows) { assertTrumpetMusiciansRows(response); } + }); + }); + + it('fromSearch(op.param("q")) with collectionQuery binding is accepted by the server', function() { + const plan = op.fromSearch(op.param('q')); + + return db.rows.query(plan, null, { q: op.cts.collectionQuery('/optic/test') }) + .then(function(response) { + // response may be null when /optic/test collection has no documents + if (response && response.rows) { + response.rows.length.should.be.above(0); + } + }); + }); + + it('fromSearchDocs.where(op.param("q")) with CtsQuery binding is accepted by the server', function() { + const plan = op + .fromSearchDocs(op.cts.wordQuery('a')) + .where(op.param('q')) + .select(['uri', 'doc']); + + return db.rows.query(plan, null, { q: op.cts.wordQuery('trumpet') }) + .then(function(response) { + if (response && response.rows) { + response.rows.length.should.be.above(0); + response.rows.forEach(row => row.should.have.property('uri')); + } + }); + }); + + it('fromSearchDocs with mixed plain-string and CtsQuery bindings via bindingArg', function() { + // Regression: plain string values in bindingArg previously crashed with + // "SyntaxError: Unexpected token" because the code tried JSON.parse on them. + const plan = op + .fromSearchDocs(op.cts.wordQuery(['saxophone', op.cts.param('searchWord')])) + .where(op.cts.param('query')) + .select(['uri', 'doc']); + + return db.rows.query(plan, null, { searchWord: 'trumpet', query: op.cts.wordQuery('trumpet') }) + .then(function(response) { + if (response && response.rows) { assertTrumpetMusiciansRows(response); } + }); + }); + + it('negative: plain object binding reaches server without JS crash', function() { + // A plain object (no _ns/_fn/_args) bypasses the rows.js interception; + // it is JSON-serialised and sent as a regular binding. The server will + // reject the malformed value. We just verify no JS-level crash occurs. + const plan = op + .fromSearchDocs(op.param('q')) + .select(['uri']); + + return db.rows.query(plan, null, { q: { notAQuery: true } }) + .then(function() { + should.fail('expected the server to reject the malformed binding'); + }) + .catch(function(err) { + should.exist(err, 'expected an error from the server'); + }); + }); + + }); + }); diff --git a/test-typescript/optic-bindparam-ctsquery-runtime.test.ts b/test-typescript/optic-bindparam-ctsquery-runtime.test.ts new file mode 100644 index 00000000..e4fe861d --- /dev/null +++ b/test-typescript/optic-bindparam-ctsquery-runtime.test.ts @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2015-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + */ + +/// + +/** + * Runtime smoke tests for MLE-29889. + * + * Covers plan-construction and export-shape cases from the test plan (cases 0a–10). + * No MarkLogic server connection required — all tests call plan.export() only. + * + * Run with: npm run test:compile && npx mocha test-typescript/optic-bindparam-ctsquery-runtime.test.js + */ + +import should = require('should'); +const marklogic = require('../lib/marklogic.js'); + +const op = marklogic.planBuilder; + +// Recursively find a node with the given ns and fn anywhere in an exported plan tree. +function findNode(obj: any, ns: string, fn: string): any { + if (obj === null || typeof obj !== 'object') { return null; } + if (Array.isArray(obj)) { + for (const item of obj) { + const found = findNode(item, ns, fn); + if (found) { return found; } + } + return null; + } + if (obj.ns === ns && obj.fn === fn) { return obj; } + for (const key of Object.keys(obj)) { + const found = findNode(obj[key], ns, fn); + if (found) { return found; } + } + return null; +} + +describe('MLE-29889 smoke tests — bindParam/fromSearchDocs/fromSearch/where/composite-CTS (no server)', function() { + + // ─── cts.param() as direct sub-query of composite CTS functions ─────── + + describe('cts.param() valid as direct sub-query of composite CTS functions', function() { + + it('op.cts.orQuery([wordQuery, cts.param()]) does not throw', function() { + should.doesNotThrow(() => { + op.cts.orQuery([op.cts.wordQuery('dog'), op.cts.param('q')]); + }); + }); + + it('exported plan contains {ns:"cts", fn:"param", args:["q"]} nested inside or-query', function() { + const plan = op.fromView('s', 'v').where( + op.cts.orQuery([op.cts.wordQuery('dog'), op.cts.param('q')]) + ); + const exported = plan.export(); + const paramNode = findNode(exported, 'cts', 'param'); + should.exist(paramNode, 'expected a cts.param node in the exported plan'); + paramNode.ns.should.equal('cts'); + paramNode.fn.should.equal('param'); + paramNode.args[0].should.equal('q'); + }); + + it('op.cts.andQuery([collectionQuery, cts.param()]) does not throw', function() { + should.doesNotThrow(() => { + op.cts.andQuery([op.cts.collectionQuery('/c'), op.cts.param('q')]); + }); + }); + + }); + + // ─── Plan#bindParam accepts CtsQuery ───────────────────────────────── + + describe('Plan#bindParam accepts CtsQuery as literal', function() { + + it('bindParam("q", op.cts.wordQuery("cat")) does not throw', function() { + should.doesNotThrow(() => { + op.fromView('s', 'v').bindParam('q', op.cts.wordQuery('cat')); + }); + }); + + it('exported bind-param has {ns:"cts", fn:"word-query", args:["cat"]} as literal arg', function() { + const plan = op.fromView('s', 'v').bindParam('q', op.cts.wordQuery('cat')); + const exported = plan.export(); + const bindNode = findNode(exported, 'op', 'bind-param'); + should.exist(bindNode, 'expected bind-param node in exported plan'); + const lit = bindNode.args[1]; + lit.ns.should.equal('cts'); + lit.fn.should.equal('word-query'); + lit.args[0].should.equal('cat'); + }); + + it('nested andQuery([wordQuery("a"), wordQuery("b")]) as bindParam literal exports correctly', function() { + const plan = op.fromView('s', 'v').bindParam('q', + op.cts.andQuery([op.cts.wordQuery('a'), op.cts.wordQuery('b')]) + ); + const exported = plan.export(); + const bindNode = findNode(exported, 'op', 'bind-param'); + should.exist(bindNode, 'expected bind-param node in exported plan'); + const lit = bindNode.args[1]; + lit.ns.should.equal('cts'); + lit.fn.should.equal('and-query'); + // and-query wraps its sub-queries in an outer array; args[0] is that array + lit.args[0].should.be.an.Array().and.have.length(2); + }); + + it('bindParam("q", op.cts.param("placeholder")) does not throw', function() { + should.doesNotThrow(() => { + op.fromView('s', 'v').bindParam('q', op.cts.param('placeholder')); + }); + }); + + it('negative: bindParam with plain object {invalid:true} still throws', function() { + should.throws(() => { + op.fromView('s', 'v').bindParam('q', { invalid: true }); + }); + }); + + it('negative: bindParam with op.param("x") (PlanParam — a plan placeholder, not a value) still throws', function() { + // op.param() is a plan-level op placeholder, not a concrete value. + // It is distinct from op.cts.param() (a CTS-tree placeholder) which case 4 accepts. + should.throws(() => { + op.fromView('s', 'v').bindParam('q', op.param('x')); + }); + }); + + }); + + // ─── fromSearchDocs / fromSearch / where accept op.param() ────── + + describe('op.param() accepted by fromSearchDocs, fromSearch, where', function() { + + it('op.fromSearchDocs(op.param("inputQuery")) does not throw', function() { + should.doesNotThrow(() => { + op.fromSearchDocs(op.param('inputQuery')); + }); + }); + + it('op.fromSearch(op.param("inputQuery")) does not throw', function() { + should.doesNotThrow(() => { + op.fromSearch(op.param('inputQuery')); + }); + }); + + it('.where(op.param("whereQuery")) does not throw', function() { + should.doesNotThrow(() => { + op.fromView('s', 'v').where(op.param('whereQuery')); + }); + }); + + it('fromSearchDocs(op.param("q")) exports {ns:"op", fn:"param", args:["q"]} at query arg', function() { + const plan = op.fromSearchDocs(op.param('q')).select(['uri', 'doc']); + const exported = plan.export(); + const fsNode = findNode(exported, 'op', 'from-search-docs'); + should.exist(fsNode, 'expected from-search-docs node in exported plan'); + const queryArg = fsNode.args[0]; + queryArg.ns.should.equal('op'); + queryArg.fn.should.equal('param'); + queryArg.args[0].should.equal('q'); + }); + + }); + + // ─── fromSearchDocs / fromSearch / where accept op.cts.param() ─────── + + describe('op.cts.param() accepted by fromSearchDocs, fromSearch, where', function() { + + it('op.fromSearchDocs(op.cts.param("inputQuery")) does not throw', function() { + should.doesNotThrow(() => { + op.fromSearchDocs(op.cts.param('inputQuery')); + }); + }); + + it('op.fromSearch(op.cts.param("inputQuery")) does not throw', function() { + should.doesNotThrow(() => { + op.fromSearch(op.cts.param('inputQuery')); + }); + }); + + it('.where(op.cts.param("whereQuery")) does not throw', function() { + should.doesNotThrow(() => { + op.fromView('s', 'v').where(op.cts.param('whereQuery')); + }); + }); + + it('fromSearchDocs(op.cts.param("q")) exports {ns:"cts", fn:"param", args:["q"]} at query arg', function() { + const plan = op.fromSearchDocs(op.cts.param('q')).select(['uri', 'doc']); + const exported = plan.export(); + const fsNode = findNode(exported, 'op', 'from-search-docs'); + should.exist(fsNode, 'expected from-search-docs node in exported plan'); + const queryArg = fsNode.args[0]; + queryArg.ns.should.equal('cts'); + queryArg.fn.should.equal('param'); + queryArg.args[0].should.equal('q'); + }); + + it('fromSearch(op.cts.param("q")) exports {ns:"cts", fn:"param", args:["q"]} at query arg', function() { + const plan = op.fromSearch(op.cts.param('q')); + const exported = plan.export(); + const fsNode = findNode(exported, 'op', 'from-search'); + should.exist(fsNode, 'expected from-search node in exported plan'); + const queryArg = fsNode.args[0]; + queryArg.ns.should.equal('cts'); + queryArg.fn.should.equal('param'); + queryArg.args[0].should.equal('q'); + }); + + it('.where(op.cts.param("q")) exports {ns:"cts", fn:"param", args:["q"]} in where clause', function() { + const plan = op.fromView('s', 'v').where(op.cts.param('q')); + const exported = plan.export(); + const whereNode = findNode(exported, 'op', 'where'); + should.exist(whereNode, 'expected where node in exported plan'); + const queryArg = whereNode.args[0]; + queryArg.ns.should.equal('cts'); + queryArg.fn.should.equal('param'); + queryArg.args[0].should.equal('q'); + }); + + }); + +});