From f8cec86cefcf537c479ade194a3c0061bac6e62f Mon Sep 17 00:00:00 2001 From: Rita Chen Date: Tue, 30 Jun 2026 14:23:55 -0400 Subject: [PATCH 1/8] MLE-29889 add param binding support for CTS queries in Optic plans --- lib/plan-builder-base.js | 7 + lib/plan-builder-generated.js | 10 +- lib/requester.js | 4 +- lib/rows.js | 70 +++++- test-basic/optic-cts-param-test.js | 196 +++++++++++++++- .../optic-bindparam-ctsquery-runtime.test.ts | 220 ++++++++++++++++++ 6 files changed, 493 insertions(+), 14 deletions(-) create mode 100644 test-typescript/optic-bindparam-ctsquery-runtime.test.ts diff --git a/lib/plan-builder-base.js b/lib/plan-builder-base.js index c983e446..4fbbadb1 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.). A global bypass + // here avoids updating every composite CTS function paramdef. Serialisation already works: + // exportObject converts _ns/_fn/_args -> ns/fn/args. + if (arg._ns === 'cts' && arg._fn === 'param') { + 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..856916d2 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,24 @@ function Rows(client) { * binding. * @returns {Promise} A Promise. */ +// Walk an exported plan JSON node and replace every cts:param / op:param +// reference whose first arg equals `name` with the given replacement node. +function substitutePlanParam(node, name, replacement) { + if (node === null || typeof node !== 'object') { return node; } + if (Array.isArray(node)) { + return node.map(item => substitutePlanParam(item, name, replacement)); + } + if ((node.ns === 'cts' || node.ns === 'op') && node.fn === 'param' && + Array.isArray(node.args) && node.args[0] === name) { + return replacement; + } + const result = {}; + for (const key of Object.keys(node)) { + result[key] = substitutePlanParam(node[key], name, replacement); + } + return result; +} + Rows.prototype.query = function queryRows() { const args = mlutil.asArray.apply(null, arguments); @@ -149,10 +168,53 @@ 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)) { + let planJson = null; + + if (substitutedOptionBindings) { + const remaining = {}; + for (const [k, v] of Object.entries(substitutedOptionBindings)) { + if (v !== null && typeof v === 'object' && '_ns' in v && '_fn' in v && '_args' in v) { + if (planJson === null) { planJson = builtPlan.export(); } + planJson = substitutePlanParam(planJson, k, bldrbase.exportArg(v)); + } else { + remaining[k] = v; + } + } + if (planJson !== null) { + substitutedOptionBindings = Object.keys(remaining).length > 0 ? remaining : null; + } + } + + if (bindingArg) { + const remaining = {}; + for (const [k, v] of Object.entries(bindingArg)) { + if (v !== null && typeof v === 'object' && '_ns' in v && '_fn' in v && '_args' in v) { + if (planJson === null) { planJson = builtPlan.export(); } + planJson = substitutePlanParam(planJson, k, bldrbase.exportArg(v)); + } else { + remaining[k] = v; + } + } + if (planJson !== null) { + bindingArg = Object.keys(remaining).length > 0 ? remaining : null; + } + } + + if (planJson !== null) { + builtPlan = JSON.stringify(planJson); + } + } + 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 +225,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'); diff --git a/test-basic/optic-cts-param-test.js b/test-basic/optic-cts-param-test.js index 298fa1a3..95374672 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,7 @@ let serverConfiguration = {}; const db = marklogic.createDatabaseClient(testconfig.restWriterConnection); const op = marklogic.planBuilder; -describe('cts.param integration tests (MLE-27883)', function() { +describe('cts.param integration tests', function() { this.timeout(10000); // Allow 10 seconds for server queries before(function(done) { @@ -43,6 +42,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 // ────────────────────────────────────────────────────────────────────────────── @@ -248,4 +249,193 @@ 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/andQuery', function() { + + it('orQuery with cts.param sub-query is accepted by the server', function() { + // The string binding for a cts.param inside orQuery is handled by the server; + // whether rows are returned depends on test data in the environment. + const plan = op + .fromSearchDocs(op.cts.orQuery([ + op.cts.wordQuery('saxophone'), + op.cts.param('searchWord') + ])) + .select(['uri', 'doc']); + + return db.rows.query(plan, { + bindings: { + searchWord: { value: 'trumpet', type: 'string' } + } + }) + .then(function(response) { + // response may be null when no documents match on this server + if (response && response.rows) { + response.rows.length.should.be.above(0, 'if rows returned, expect at least one match'); + } + }); + }); + + it('orQuery with 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', 'doc']); + + return db.rows.query(plan, { bindings: { query: op.cts.wordQuery('trumpet') } }) + .then(function(response) { + // response may be null when no documents match on this server + if (response && response.rows) { + response.rows.length.should.be.above(0, 'if rows returned, expect at least one match'); + } + }); + }); + + }); + + // ────────────────────────────────────────────────────────────────────────────── + // Plan#bindParam 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) { + // response may be null when no matching documents exist on this server + if (response && response.rows) { + response.rows.forEach(row => { + row.should.have.property('uri'); + row.should.have.property('doc'); + }); + } + }); + }); + + 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) { + // response may be null when no matching documents exist on this server + if (response && response.rows) { + response.rows.forEach(row => { + row.should.have.property('uri'); + row.should.have.property('doc'); + }); + } + }); + }); + + 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) { + // response may be null when no matching documents exist on this server + if (response && response.rows) { + response.rows.length.should.be.above(0, 'if rows returned, expect at least one match'); + response.rows.forEach(row => { + row.should.have.property('uri'); + row.should.have.property('doc'); + }); + } + }); + }); + + }); + + // ────────────────────────────────────────────────────────────────────────────── + // op.param() bound to CtsQuery at runtime (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 embeds them in + // the plan via bindParam before sending, so the server sees them as plan literals. + // ────────────────────────────────────────────────────────────────────────────── + + describe('op.param() bound to CtsQuery at runtime via third arg', function() { + + it('fromSearchDocs(op.param("q")) with CtsQuery binding is accepted by the server', function() { + // rows.js embeds the wordQuery into the plan via bindParam; the server executes + // the bound plan. Whether rows come back depends on test data in the environment. + const plan = op + .fromSearchDocs(op.param('q')) + .select(['uri', 'doc']); + + return db.rows.query(plan, null, { q: op.cts.wordQuery('trumpet') }) + .then(function(response) { + // response may be null when no matching documents exist on this server + if (response && response.rows) { + response.rows.forEach(row => { + row.should.have.property('uri'); + row.should.have.property('doc'); + }); + } + }); + }); + + 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('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'); + }); + + }); + +}); From 2564a6cdaa1bcb9891bb6a02b07f5dbc634d9766 Mon Sep 17 00:00:00 2001 From: Rita Chen Date: Wed, 1 Jul 2026 09:47:29 -0400 Subject: [PATCH 2/8] MLE-29889 update integration tests --- test-basic/optic-cts-param-test.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/test-basic/optic-cts-param-test.js b/test-basic/optic-cts-param-test.js index 95374672..abf9f7d9 100644 --- a/test-basic/optic-cts-param-test.js +++ b/test-basic/optic-cts-param-test.js @@ -255,11 +255,9 @@ describe('cts.param integration tests', function() { // cts.param() as direct sub-query of composite CTS functions // ────────────────────────────────────────────────────────────────────────────── - describe('cts.param() as direct child of orQuery/andQuery', function() { + describe('cts.param() as direct child of orQuery', function() { - it('orQuery with cts.param sub-query is accepted by the server', function() { - // The string binding for a cts.param inside orQuery is handled by the server; - // whether rows are returned depends on test data in the environment. + it('orQuery with cts.param bound to string is accepted by the server', function() { const plan = op .fromSearchDocs(op.cts.orQuery([ op.cts.wordQuery('saxophone'), @@ -300,7 +298,7 @@ describe('cts.param integration tests', function() { }); // ────────────────────────────────────────────────────────────────────────────── - // Plan#bindParam with CtsQuery literal + // Param binding with CtsQuery literal // ────────────────────────────────────────────────────────────────────────────── describe('CtsQuery binding via options.bindings (second arg)', function() { @@ -364,18 +362,16 @@ describe('cts.param integration tests', function() { }); // ────────────────────────────────────────────────────────────────────────────── - // op.param() bound to CtsQuery at runtime (third arg) + // 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 embeds them in - // the plan via bindParam before sending, so the server sees them as plan literals. + // 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 third arg', function() { + 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() { - // rows.js embeds the wordQuery into the plan via bindParam; the server executes - // the bound plan. Whether rows come back depends on test data in the environment. const plan = op .fromSearchDocs(op.param('q')) .select(['uri', 'doc']); From 04cf8c830e9dfb074bd574b1a28a291ce41419f7 Mon Sep 17 00:00:00 2001 From: Rita Chen Date: Wed, 1 Jul 2026 13:05:43 -0400 Subject: [PATCH 3/8] MLE-29889 add a test for a common use case for fromSearchDocs --- test-basic/optic-cts-param-test.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test-basic/optic-cts-param-test.js b/test-basic/optic-cts-param-test.js index abf9f7d9..6aecc017 100644 --- a/test-basic/optic-cts-param-test.js +++ b/test-basic/optic-cts-param-test.js @@ -189,6 +189,30 @@ describe('cts.param integration tests', 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'); + 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'); + }); + }); + }); // ────────────────────────────────────────────────────────────────────────────── From 0cbb6a14c1f11bd530535a909388788a17da5951 Mon Sep 17 00:00:00 2001 From: Rita Chen Date: Wed, 1 Jul 2026 13:07:16 -0400 Subject: [PATCH 4/8] MLE-29889 restrict the bypass to only types.CtsQuery --- lib/plan-builder-base.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/plan-builder-base.js b/lib/plan-builder-base.js index 4fbbadb1..1ab6f226 100644 --- a/lib/plan-builder-base.js +++ b/lib/plan-builder-base.js @@ -65,10 +65,10 @@ function castArg(arg, funcName, paramName, argPos, paramTypes) { 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.). A global bypass - // here avoids updating every composite CTS function paramdef. Serialisation already works: - // exportObject converts _ns/_fn/_args -> ns/fn/args. - if (arg._ns === 'cts' && arg._fn === 'param') { + // 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( From b4885f42cec234a71de1b90649026979798b5b1e Mon Sep 17 00:00:00 2001 From: Rita Chen Date: Wed, 1 Jul 2026 13:11:07 -0400 Subject: [PATCH 5/8] MLE-29889 refract the plan parameter substistution routine --- lib/rows.js | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/lib/rows.js b/lib/rows.js index 856916d2..6ab620e3 100644 --- a/lib/rows.js +++ b/lib/rows.js @@ -82,6 +82,24 @@ function substitutePlanParam(node, name, replacement) { return result; } +// For each entry in bindings, if the value is an exported plan-builder node +// ({ns, fn, args[]}), substitute it into planJson and collect the rest in +// remaining. Returns the (possibly updated) planJson and the remaining bindings. +function substituteExportedBindings(bindings, planJson, builtPlan) { + 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)) { + if (planJson === null) { planJson = builtPlan.export(); } + planJson = substitutePlanParam(planJson, k, exported); + } else { + remaining[k] = v; + } + } + return { planJson, remaining }; +} + Rows.prototype.query = function queryRows() { const args = mlutil.asArray.apply(null, arguments); @@ -177,30 +195,16 @@ function queryRowsOperationImpl(self, builtPlan, streamType, options, bindingArg let planJson = null; if (substitutedOptionBindings) { - const remaining = {}; - for (const [k, v] of Object.entries(substitutedOptionBindings)) { - if (v !== null && typeof v === 'object' && '_ns' in v && '_fn' in v && '_args' in v) { - if (planJson === null) { planJson = builtPlan.export(); } - planJson = substitutePlanParam(planJson, k, bldrbase.exportArg(v)); - } else { - remaining[k] = v; - } - } + let remaining; + ({ planJson, remaining } = substituteExportedBindings(substitutedOptionBindings, planJson, builtPlan)); if (planJson !== null) { substitutedOptionBindings = Object.keys(remaining).length > 0 ? remaining : null; } } if (bindingArg) { - const remaining = {}; - for (const [k, v] of Object.entries(bindingArg)) { - if (v !== null && typeof v === 'object' && '_ns' in v && '_fn' in v && '_args' in v) { - if (planJson === null) { planJson = builtPlan.export(); } - planJson = substitutePlanParam(planJson, k, bldrbase.exportArg(v)); - } else { - remaining[k] = v; - } - } + let remaining; + ({ planJson, remaining } = substituteExportedBindings(bindingArg, planJson, builtPlan)); if (planJson !== null) { bindingArg = Object.keys(remaining).length > 0 ? remaining : null; } From 48ba286825feb2f879ff6b62e950d8edda99d4db Mon Sep 17 00:00:00 2001 From: Rita Chen Date: Wed, 1 Jul 2026 14:50:45 -0400 Subject: [PATCH 6/8] MLE-29889 rework substitutePlanParams with one replacements map --- lib/rows.js | 52 +++++++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/lib/rows.js b/lib/rows.js index 6ab620e3..d732e2f0 100644 --- a/lib/rows.js +++ b/lib/rows.js @@ -65,39 +65,39 @@ function Rows(client) { * @returns {Promise} A Promise. */ // Walk an exported plan JSON node and replace every cts:param / op:param -// reference whose first arg equals `name` with the given replacement node. -function substitutePlanParam(node, name, replacement) { +// 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 => substitutePlanParam(item, name, replacement)); + return node.map(item => substitutePlanParams(item, replacements)); } if ((node.ns === 'cts' || node.ns === 'op') && node.fn === 'param' && - Array.isArray(node.args) && node.args[0] === name) { - return replacement; + 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] = substitutePlanParam(node[key], name, replacement); + result[key] = substitutePlanParams(node[key], replacements); } return result; } -// For each entry in bindings, if the value is an exported plan-builder node -// ({ns, fn, args[]}), substitute it into planJson and collect the rest in -// remaining. Returns the (possibly updated) planJson and the remaining bindings. -function substituteExportedBindings(bindings, planJson, builtPlan) { +// 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)) { - if (planJson === null) { planJson = builtPlan.export(); } - planJson = substitutePlanParam(planJson, k, exported); + planNodeMap[k] = exported; } else { remaining[k] = v; } } - return { planJson, remaining }; + return { planNodeMap, remaining }; } Rows.prototype.query = function queryRows() { @@ -192,26 +192,26 @@ function queryRowsOperationImpl(self, builtPlan, streamType, options, bindingArg // the second-arg (options.bindings) or the third-arg (bindingArg) form. let substitutedOptionBindings = options.bindings || null; if (builtPlan instanceof planBuilder.Plan && (substitutedOptionBindings || bindingArg)) { - let planJson = null; + const replacements = {}; if (substitutedOptionBindings) { - let remaining; - ({ planJson, remaining } = substituteExportedBindings(substitutedOptionBindings, planJson, builtPlan)); - if (planJson !== null) { + 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) { - let remaining; - ({ planJson, remaining } = substituteExportedBindings(bindingArg, planJson, builtPlan)); - if (planJson !== null) { + const { planNodeMap, remaining } = partitionExportedBindings(bindingArg); + if (Object.keys(planNodeMap).length > 0) { + Object.assign(replacements, planNodeMap); bindingArg = Object.keys(remaining).length > 0 ? remaining : null; } } - if (planJson !== null) { - builtPlan = JSON.stringify(planJson); + if (Object.keys(replacements).length > 0) { + builtPlan = JSON.stringify(substitutePlanParams(builtPlan.export(), replacements)); } } @@ -247,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], From 5742d47326b088616c00ffc148df139abb49b7f9 Mon Sep 17 00:00:00 2001 From: Rita Chen Date: Wed, 1 Jul 2026 14:57:20 -0400 Subject: [PATCH 7/8] MLE-29889 add test cases for mixed plain-string and CtsQuery bindings --- test-basic/optic-cts-param-test.js | 90 +++++++++++++++++------------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/test-basic/optic-cts-param-test.js b/test-basic/optic-cts-param-test.js index 6aecc017..1db79125 100644 --- a/test-basic/optic-cts-param-test.js +++ b/test-basic/optic-cts-param-test.js @@ -23,6 +23,22 @@ let serverConfiguration = {}; const db = marklogic.createDatabaseClient(testconfig.restWriterConnection); const op = marklogic.planBuilder; +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'); + }); +} + describe('cts.param integration tests', function() { this.timeout(10000); // Allow 10 seconds for server queries @@ -201,15 +217,7 @@ describe('cts.param integration tests', function() { .then(function(response) { should.exist(response, 'response should exist'); response.should.have.property('rows'); - 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'); + assertTrumpetMusiciansRows(response); }); }); @@ -337,13 +345,7 @@ describe('cts.param integration tests', function() { return db.rows.query(plan, { bindings: { q: op.cts.wordQuery('trumpet') } }) .then(function(response) { - // response may be null when no matching documents exist on this server - if (response && response.rows) { - response.rows.forEach(row => { - row.should.have.property('uri'); - row.should.have.property('doc'); - }); - } + if (response && response.rows) { assertTrumpetMusiciansRows(response); } }); }); @@ -354,13 +356,7 @@ describe('cts.param integration tests', function() { return db.rows.query(plan, { bindings: { q: op.cts.wordQuery('trumpet') } }) .then(function(response) { - // response may be null when no matching documents exist on this server - if (response && response.rows) { - response.rows.forEach(row => { - row.should.have.property('uri'); - row.should.have.property('doc'); - }); - } + if (response && response.rows) { assertTrumpetMusiciansRows(response); } }); }); @@ -372,17 +368,27 @@ describe('cts.param integration tests', function() { return db.rows.query(plan, { bindings: { query: op.cts.wordQuery('trumpet') } }) .then(function(response) { - // response may be null when no matching documents exist on this server - if (response && response.rows) { - response.rows.length.should.be.above(0, 'if rows returned, expect at least one match'); - response.rows.forEach(row => { - row.should.have.property('uri'); - row.should.have.property('doc'); - }); - } + 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); } + }); + }); + }); // ────────────────────────────────────────────────────────────────────────────── @@ -402,13 +408,7 @@ describe('cts.param integration tests', function() { return db.rows.query(plan, null, { q: op.cts.wordQuery('trumpet') }) .then(function(response) { - // response may be null when no matching documents exist on this server - if (response && response.rows) { - response.rows.forEach(row => { - row.should.have.property('uri'); - row.should.have.property('doc'); - }); - } + if (response && response.rows) { assertTrumpetMusiciansRows(response); } }); }); @@ -439,6 +439,20 @@ describe('cts.param integration tests', function() { }); }); + 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 From e4bdaf3ff1d0e50a731fffe72dea7b957cd24082 Mon Sep 17 00:00:00 2001 From: Rita Chen Date: Thu, 2 Jul 2026 13:45:20 -0400 Subject: [PATCH 8/8] MLE-29889 refine test cases for orQuery test --- test-basic/optic-cts-param-test.js | 62 +++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/test-basic/optic-cts-param-test.js b/test-basic/optic-cts-param-test.js index 1db79125..31619423 100644 --- a/test-basic/optic-cts-param-test.js +++ b/test-basic/optic-cts-param-test.js @@ -39,6 +39,16 @@ function assertTrumpetMusiciansRows(response) { }); } +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 @@ -287,15 +297,29 @@ describe('cts.param integration tests', function() { // cts.param() as direct sub-query of composite CTS functions // ────────────────────────────────────────────────────────────────────────────── - describe('cts.param() as direct child of orQuery', function() { + describe('cts.param as direct child of orQuery', function() { - it('orQuery with cts.param bound to string is accepted by the server', function() { + it('orQuery with literal string is accepted by the server', function() { const plan = op .fromSearchDocs(op.cts.orQuery([ - op.cts.wordQuery('saxophone'), + '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', 'doc']); + .select(['uri']); return db.rows.query(plan, { bindings: { @@ -303,27 +327,39 @@ describe('cts.param integration tests', function() { } }) .then(function(response) { - // response may be null when no documents match on this server - if (response && response.rows) { - response.rows.length.should.be.above(0, 'if rows returned, expect at least one match'); + 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 cts.param bound to CtsQuery via options.bindings', function() { + 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', 'doc']); + .select(['uri']); return db.rows.query(plan, { bindings: { query: op.cts.wordQuery('trumpet') } }) .then(function(response) { - // response may be null when no documents match on this server - if (response && response.rows) { - response.rows.length.should.be.above(0, 'if rows returned, expect at least one match'); - } + if (response && response.rows) { assertAllMusiciansRows(response); } }); });