@sap/cds 8.0.4 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/_i18n/i18n_bg.properties +113 -0
  3. package/_i18n/i18n_el.properties +113 -0
  4. package/_i18n/i18n_he.properties +113 -0
  5. package/_i18n/i18n_hr.properties +113 -0
  6. package/_i18n/i18n_kk.properties +113 -0
  7. package/_i18n/i18n_sh.properties +113 -0
  8. package/_i18n/i18n_sk.properties +113 -0
  9. package/_i18n/i18n_sl.properties +113 -0
  10. package/_i18n/i18n_uk.properties +113 -0
  11. package/lib/compile/etc/_localized.js +8 -20
  12. package/lib/dbs/cds-deploy.js +1 -0
  13. package/lib/env/defaults.js +1 -1
  14. package/lib/env/plugins.js +22 -6
  15. package/lib/linked/validate.js +1 -1
  16. package/lib/log/cds-log.js +2 -2
  17. package/lib/srv/protocols/hcql.js +5 -5
  18. package/lib/srv/protocols/http.js +23 -11
  19. package/lib/test/expect.js +1 -1
  20. package/lib/utils/cds-test.js +4 -4
  21. package/libx/_runtime/common/error/utils.js +2 -1
  22. package/libx/_runtime/common/generic/input.js +2 -5
  23. package/libx/_runtime/common/generic/stream.js +18 -3
  24. package/libx/_runtime/common/utils/cqn2cqn4sql.js +5 -2
  25. package/libx/_runtime/fiori/lean-draft.js +1 -1
  26. package/libx/_runtime/hana/customBuilder/CustomReferenceBuilder.js +1 -1
  27. package/libx/_runtime/messaging/enterprise-messaging-utils/registerEndpoints.js +2 -2
  28. package/libx/_runtime/messaging/event-broker.js +23 -9
  29. package/libx/common/assert/utils.js +1 -57
  30. package/libx/odata/middleware/batch.js +5 -6
  31. package/libx/odata/middleware/body-parser.js +2 -3
  32. package/libx/odata/middleware/operation.js +11 -11
  33. package/libx/odata/parse/grammar.peggy +6 -1
  34. package/libx/odata/parse/parser.js +1 -1
  35. package/libx/odata/utils/metadata.js +18 -44
  36. package/libx/rest/middleware/parse.js +1 -1
  37. package/package.json +1 -1
  38. package/libx/common/assert/index.js +0 -228
  39. package/libx/common/assert/type-relaxed.js +0 -39
@@ -17,38 +17,50 @@ class HttpAdapter {
17
17
 
18
18
  /** The actual Router factory. Subclasses override this to add specific handlers. */
19
19
  get router() {
20
- let router = super.router = (new express.Router) .use (this.http_log.bind(this))
21
- let assert_roles = this.requires_check()
22
- if (assert_roles) router.use (assert_roles)
20
+ let router = super.router = (new express.Router)
21
+ this.use (this.http_log)
22
+ this.use (this.requires_check)
23
23
  return router
24
24
  }
25
25
 
26
- /** Handler to log all incoming requests */
27
- http_log (r,_,next) {
28
- this.logger = cds.log(this.kind)
29
- this.log(r)
30
- next()
26
+ use (middleware) {
27
+ if (middleware) this.router.use (middleware)
28
+ return this
31
29
  }
32
30
 
33
31
  /** Subclasses may override this method to log incoming requests. */
34
- log (req, LOG = this.logger) { LOG._info && LOG.info (
32
+ log (req, LOG = this.logger) { LOG.info (
35
33
  req.method,
36
34
  decodeURI (req.baseUrl + req.path),
37
35
  Object.keys (req.query).length ? { ...req.query } : ''
38
36
  )}
39
37
 
38
+ /** Returns a handler to log all incoming requests */
39
+ get http_log() {
40
+ const LOG = this.logger = cds.log(this.kind); if (!LOG._info) return undefined
41
+ const log = this.log.bind(this)
42
+ return function http_log (req,_,next) { log(req,LOG); next() }
43
+ }
44
+
40
45
  /** Returns a handler to check required roles, or null if no check required. */
41
- requires_check() {
46
+ get requires_check() {
42
47
  const d = this.service.definition
43
48
  const roles = d['@requires'] || d['@restrict']?.map(r => r.to).flat().filter(r => r)
44
49
  const required = !roles?.length ? restricted_by_default : Array.isArray(roles) ? roles : [roles]
45
- if (required) return function requires_check (req, res, next) {
50
+ return required && function requires_check (req, res, next) {
46
51
  const user = cds.context.user
47
52
  if (required.some(role => user.has(role))) return next()
48
53
  else if (user._is_anonymous) return next(401) // request login
49
54
  else throw Object.assign(new Error, { code: 403, reason: `User '${user.id}' is lacking required roles: [${required}]`, user, required })
50
55
  }
51
56
  }
57
+
58
+ get body_parser_options() {
59
+ let options = cds.env.server.body_parser
60
+ let limit = this.service.definition['@cds.server.body_parser.limit']
61
+ if (limit) options = { ...options, limit }
62
+ return super.body_parser_options = options
63
+ }
52
64
  }
53
65
 
54
66
 
@@ -112,7 +112,7 @@ class Core {
112
112
  if (is.string(a)) return a.includes(x)
113
113
  if (is.array(a)) return a.includes(x) || this._deep && a.some(o => compare(o, x, true))
114
114
  if (is.set(a)) return a.has(x)
115
- if (this._deep && is.object(a)) return compare(a, x, true)
115
+ if (is.object(a)) return compare(a, x, this._deep)
116
116
  }, _fail)
117
117
  }
118
118
 
@@ -160,7 +160,7 @@ class Test extends require('./axios') {
160
160
  `)}}
161
161
  }
162
162
  set expect(x) { super.expect = x }
163
- get expect() { return this.chai.expect }
163
+ get expect() { return _expect || this.chai.expect }
164
164
  get assert() { return this.chai.assert }
165
165
  get should() { return this.chai.should() }
166
166
  }
@@ -174,6 +174,7 @@ Object.setPrototypeOf (exports, Test.prototype)
174
174
 
175
175
 
176
176
  // Provide same global functions for jest and mocha
177
+ let _expect = undefined
177
178
  ;(function _support_jest_and_mocha() {
178
179
  const _global = p => Object.getOwnPropertyDescriptor(global,p)?.value
179
180
  const is_jest = _global('beforeAll')
@@ -185,7 +186,7 @@ Object.setPrototypeOf (exports, Test.prototype)
185
186
  global.afterAll = global.after = (msg,fn) => repl.on?.('exit',fn||msg)
186
187
  global.beforeEach = global.afterEach = ()=>{}
187
188
  global.describe = ()=>{}
188
- exports.expect = global.expect = require('../test/expect')
189
+ global.expect = _expect = require('../test/expect')
189
190
 
190
191
  } else if (is_mocha) { // it's mocha
191
192
 
@@ -219,8 +220,7 @@ Object.setPrototypeOf (exports, Test.prototype)
219
220
  global.afterAll = global.after = (msg,fn) => after(fn||msg)
220
221
  global.beforeEach = beforeEach
221
222
  global.afterEach = afterEach
222
- global.expect = require('../test/expect')
223
- exports.expect = global.expect
223
+ global.expect = _expect = require('../test/expect')
224
224
  suite ('<next>', ()=>{}) //> to signal the start of a test file
225
225
 
226
226
  }
@@ -12,7 +12,8 @@ const i18n = (...args) => {
12
12
  * @returns localized error message
13
13
  */
14
14
  function getErrorMessage(error, locale) {
15
- const txt = i18n(error.message || error.code || error.status || error.statusCode, locale, error.args)
15
+ const key = error.message || error.code || error.status || error.statusCode || '500'
16
+ const txt = i18n(key, locale, error.args)
16
17
  return txt || error.message || String(error.code || error.status || error.statusCode)
17
18
  }
18
19
 
@@ -349,15 +349,12 @@ const _getOperation = (req, service) => {
349
349
 
350
350
  function _actionFunctionHandler(req) {
351
351
  const operation = _getOperation(req, this)
352
- if (!operation || !operation.params) return
352
+ if (!operation) return
353
353
 
354
354
  const data = req.data || {}
355
355
 
356
- // REVISIT: skip for mtxs as their models contain invalidities (e.g., properties modeled as strings but provided as objects)
357
- const is_mtxs = operation.name.match(/^cds\.xt\./)
358
-
359
356
  // validate data
360
- if (cds.env.features.cds_validate && !is_mtxs) {
357
+ if (cds.env.features.cds_validate) {
361
358
  const assertOptions = { mandatories: true }
362
359
  let errs = cds.validate(data, operation, assertOptions)
363
360
  if (errs) {
@@ -1,4 +1,14 @@
1
1
  const cds = require('../../cds')
2
+ // REVISIT: Remove after removing okra
3
+ const { isStreaming } = require('../../cds-services/adapter/odata-v4/utils/stream')
4
+
5
+ const _isStream = query => {
6
+ const { _propertyAccess, target } = query
7
+ if (!_propertyAccess) return
8
+
9
+ const element = target.elements[_propertyAccess]
10
+ return element._type === 'cds.LargeBinary' && element['@Core.MediaType']
11
+ }
2
12
 
3
13
  const _getStreamingProperties = elements => {
4
14
  const result = []
@@ -14,9 +24,7 @@ const _getStreamingProperties = elements => {
14
24
 
15
25
  const _getMediaTypeValue = () => {
16
26
  const ctx = cds.context
17
- return (
18
- !ctx?.http?.req?.headers?.['content-type']?.match(/json|multipart/i) && ctx?.http?.req?.headers?.['content-type']
19
- )
27
+ return !ctx?.http?.req?.headers?.['content-type']?.match(/multipart/i) && ctx?.http?.req?.headers?.['content-type']
20
28
  }
21
29
 
22
30
  function _addContentType(req, mtValue) {
@@ -27,12 +35,19 @@ function _addContentType(req, mtValue) {
27
35
 
28
36
  async function addContentType(req) {
29
37
  if (!req.query || !req.target) return
38
+ if (req._.odataReq) {
39
+ if (!isStreaming(req._.odataReq.getUriInfo().getPathSegments())) return
40
+ } else if (req.req?._query) {
41
+ if (!_isStream(req.req._query)) return
42
+ }
43
+
30
44
  const mtValue = _getMediaTypeValue()
31
45
  if (!mtValue) return
32
46
 
33
47
  _addContentType(req, mtValue)
34
48
  }
35
49
 
50
+ // register after input.js in order to write content-type also for @Core.Computed fields
36
51
  module.exports = cds.service.impl(function () {
37
52
  this.before(['PATCH', 'UPDATE'], '*', addContentType)
38
53
  })
@@ -758,13 +758,16 @@ const _convertSelect = (query, model, _options) => {
758
758
  // old db expects it as cqn xpr
759
759
  if (query.SELECT.search.length === 1) {
760
760
  query.SELECT.search = query.SELECT.search[0].val
761
- .replace(/"/g, '')
762
- .split(' ')
761
+ .match(/("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/g)
762
+ .filter(el => el.length)
763
+ .map(el => (el.match(/^["](.*)["]$/) ? JSON.parse(el) : el))
763
764
  .reduce((arr, val, i) => {
764
765
  if (i > 0) arr.push('and')
765
766
  arr.push({ val })
766
767
  return arr
767
768
  }, [])
769
+
770
+ if (!query.SELECT.search.length) query.SELECT.search = [{ val: '' }]
768
771
  }
769
772
 
770
773
  search2cqn4sql(query, model, { ...query._searchOptions, ...{ entityName, alias } })
@@ -207,7 +207,7 @@ const _redirectRefToActives = (ref, model) => {
207
207
  }
208
208
 
209
209
  const lastCheckMap = new Map()
210
- const _cleanUpOldDrafts = async (service, tenant) => {
210
+ const _cleanUpOldDrafts = (service, tenant) => {
211
211
  if (!DEL_TIMEOUT.value) return
212
212
 
213
213
  const expiryDate = new Date(Date.now() - DEL_TIMEOUT.value).toISOString()
@@ -15,7 +15,7 @@ class CustomReferenceBuilder extends ReferenceBuilder {
15
15
  const args = Object.keys(ref[0].args)
16
16
  .map(argKey => {
17
17
  this._outputObj.values.push(ref[0].args[argKey].val)
18
- return `${argKey} => ${this._options.placeholder}`
18
+ return `${this._quoteElement(argKey)} => ${this._options.placeholder}`
19
19
  })
20
20
  .join(', ')
21
21
 
@@ -22,7 +22,7 @@ class EndpointRegistry {
22
22
  // unsuccessful auth doesn't automatically reject!
23
23
  cds.app.use(basePath, (req, res, next) => {
24
24
  // REVISIT: we should probably pass an error into next so that a (custom) error middleware can handle it
25
- if (!cds.context.user._is_anonymous) res.status(401).json({ error: ODATA_UNAUTHORIZED })
25
+ if (cds.context.user._is_anonymous) return res.status(401).json({ error: ODATA_UNAUTHORIZED })
26
26
  next()
27
27
  })
28
28
  } else if (process.env.NODE_ENV === 'production') {
@@ -32,7 +32,7 @@ class EndpointRegistry {
32
32
  cds.app.use(basePath, cds.middlewares.context())
33
33
  }
34
34
  cds.app.use(basePath, express.json({ type: 'application/*+json' }))
35
- cds.app.use(basePath, express.json()) // REVISIT: Do we need both?
35
+ cds.app.use(basePath, express.json())
36
36
  cds.app.use(basePath, express.urlencoded({ extended: true }))
37
37
  LOG._debug && LOG.debug('Register inbound endpoint', { basePath, method: 'OPTIONS' })
38
38
 
@@ -48,31 +48,34 @@ function _validateCertificate(req, res, next) {
48
48
  return res.status(401).json({ message: 'Authentication Failed' })
49
49
  }
50
50
 
51
- const bindingCert = new crypto.X509Certificate(this.options.credentials.certificate).toLegacyObject()
52
- const clientCert = new crypto.X509Certificate(
51
+ const clientCertObj = new crypto.X509Certificate(
53
52
  `-----BEGIN CERTIFICATE-----\n${req.headers['x-forwarded-client-cert']}\n-----END CERTIFICATE-----`
54
- ).toLegacyObject()
53
+ )
54
+ const clientCert = clientCertObj.toLegacyObject()
55
+
56
+ if (!this.isMultitenancy && !clientCertObj.checkPrivateKey(this.privateKey))
57
+ return res.status(401).josn({ message: 'Authentication Failed' })
55
58
 
56
59
  const cfSubject = Buffer.from(req.headers['x-ssl-client-subject-cn'], 'base64').toString()
57
- if (bindingCert.subject.CN !== clientCert.subject.CN || bindingCert.subject.CN !== cfSubject) {
60
+ if (this.validationCert.subject.CN !== clientCert.subject.CN || this.validationCert.subject.CN !== cfSubject) {
58
61
  this.LOG.info('certificate subject does not match')
59
62
  return res.status(401).json({ message: 'Authentication Failed' })
60
63
  }
61
64
  this.LOG.debug('incoming Subject CN is valid.')
62
65
 
63
- if (bindingCert.issuer.CN !== clientCert.issuer.CN) {
66
+ if (this.validationCert.issuer.CN !== clientCert.issuer.CN) {
64
67
  this.LOG.info('Certificate issuer subject does not match')
65
68
  return res.status(401).json({ message: 'Authentication Failed' })
66
69
  }
67
70
  this.LOG.debug('incoming issuer subject CN is valid.')
68
71
 
69
- if (bindingCert.issuer.O !== clientCert.issuer.O) {
72
+ if (this.validationCert.issuer.O !== clientCert.issuer.O) {
70
73
  this.LOG.info('Certificate issuer org does not match')
71
74
  return res.status(401).json({ message: 'Authentication Failed' })
72
75
  }
73
76
  this.LOG.debug('incoming Issuer Org is valid.')
74
77
 
75
- if (bindingCert.issuer.OU !== clientCert.issuer.OU) {
78
+ if (this.validationCert.issuer.OU !== clientCert.issuer.OU) {
76
79
  this.LOG.info('certificate issuer OU does not match')
77
80
  return res.status(401).json({ message: 'Authentication Failed' })
78
81
  }
@@ -103,6 +106,11 @@ class EventBroker extends cds.MessagingService {
103
106
  this.startListening()
104
107
  })
105
108
  this.agent = this.getAgent()
109
+ this.isMultitenancy = cds.requires.multitenancy || cds.env.profiles.includes('mtx-sidecar')
110
+ this.validationCert = new crypto.X509Certificate(
111
+ this.isMultitenancy ? this.options.credentials.certificate : this.agent.options.cert
112
+ ).toLegacyObject()
113
+ this.privateKey = !this.isMultitenancy && crypto.createPrivateKey(this.agent.options.key)
106
114
  }
107
115
 
108
116
  getAgent() {
@@ -189,7 +197,13 @@ class EventBroker extends cds.MessagingService {
189
197
  }
190
198
 
191
199
  prepareHeaders(headers, event) {
192
- if (!('source' in headers)) headers.source = `${this.options.credentials.ceSource[0]}/${cds.context.tenant}`
200
+ if (!('source' in headers)) {
201
+ if (!this.options.credentials.ceSource)
202
+ throw new Error(
203
+ 'Cannot publish event because of missing source information, currently not part of binding information.'
204
+ )
205
+ headers.source = `${this.options.credentials.ceSource[0]}/${cds.context.tenant}`
206
+ }
193
207
  super.prepareHeaders(headers, event)
194
208
  }
195
209
 
@@ -214,7 +228,7 @@ class EventBroker extends cds.MessagingService {
214
228
  const msg = normalizeIncomingMessage(req.body)
215
229
  msg.event = event
216
230
  Object.assign(msg.headers, headers)
217
- if (tenant) msg.tenant = tenant
231
+ if (this.isMultitenancy) msg.tenant = tenant
218
232
 
219
233
  // for cds.context.http
220
234
  msg._ = {}
@@ -1,30 +1,3 @@
1
- function getNested(k, obj) {
2
- let cur = obj
3
- let p = ''
4
- const parts = k.split('_')
5
- while (parts.length) {
6
- const q = parts.shift()
7
- if (q in cur) {
8
- cur = cur[q]
9
- p = ''
10
- } else {
11
- p = p ? p + '_' + q : q
12
- if (p in cur) {
13
- cur = cur[p]
14
- p = ''
15
- } else {
16
- if (Object.keys(cur).some(k => k.startsWith(p + '_'))) {
17
- // continue for now as there's still a chance
18
- } else {
19
- // abort
20
- return undefined
21
- }
22
- }
23
- }
24
- }
25
- return cur[p] || cur !== obj ? cur : undefined
26
- }
27
-
28
1
  const getNormalizedDecimal = val => {
29
2
  let v = `${val}`
30
3
  const cgs = v.match(/^(\d*\.*\d*)e([+|-]*)(\d*)$/)
@@ -88,39 +61,10 @@ const resolveCDSType = ele => {
88
61
  return ele
89
62
  }
90
63
 
91
- function resolveSegment(prev, obj, def) {
92
- if (prev.keys) {
93
- let keys = []
94
- for (const k of prev.keys) {
95
- let val
96
- if (k in obj) val = obj[k]
97
- else val = getNested(k, obj)
98
- if (val == null) {
99
- // in some cases, k is not given, e.g., POST into collection via navigation
100
- // TODO: what to put in target? "null", "transient", ...?
101
- if (k === 'IsActiveEntity')
102
- keys.push(`${k}=false`) //> always false if not in obj as it must be a draft activate
103
- else keys.push(`${k}=null`)
104
- } else {
105
- const cdsType = resolveCDSType(def.elements[k])
106
- const odataType = def.elements[k]['@odata.Type']
107
- if (!odataType && cdsType === 'cds.String' || odataType === 'Edm.String') val = `'${val}'`
108
- // TODO: more proper val encoding based on type
109
- keys.push(`${k}=${val}`)
110
- }
111
- }
112
- return `${prev.assoc}(${keys.join(',')})`
113
- }
114
- if (prev.index) {
115
- return `${prev.prop}[${prev.index}]`
116
- }
117
- }
118
64
 
119
65
  module.exports = {
120
- getNested,
121
66
  getNormalizedDecimal,
122
67
  getTarget,
123
68
  isBase64String,
124
- resolveCDSType,
125
- resolveSegment
69
+ resolveCDSType
126
70
  }
@@ -553,12 +553,11 @@ const _formatResponseJson = (request, atomicityGroup) => {
553
553
  */
554
554
 
555
555
  module.exports = adapter => {
556
- const { options: config, router, service } = adapter
557
- const { max_content_length } = config //> max_content_length is unofficial config
558
-
559
- const options = { type: '*/*' }
560
- if (max_content_length) options.limit = max_content_length
561
- const textBodyParser = express.text(options)
556
+ const { router, service } = adapter
557
+ const textBodyParser = express.text({
558
+ ...adapter.body_parser_options,
559
+ type: '*/*' // REVISIT: why do we need to override type here?
560
+ })
562
561
 
563
562
  return function odata_batch(req, res, next) {
564
563
  if (req.headers['content-type'].includes('application/json')) {
@@ -3,9 +3,8 @@ const express = require('express')
3
3
  // basically express.json() with string representation of body stored in req._raw for recovery
4
4
  // REVISIT: why do we need our own body parser? Only because of req._raw?
5
5
  module.exports = function bodyParser4(adapter, options = {}) {
6
- if (!options.type) options.type = 'json'
7
- const { max_content_length } = adapter.options //> max_content_length is unofficial config
8
- if (!options.limit && max_content_length) options.limit = max_content_length
6
+ Object.assign(options, adapter.body_parser_options)
7
+ options.type ??= 'json' // REVISIT: why do we need to override type here?
9
8
  const textParser = express.text(options)
10
9
  return function http_body_parser(req, res, next) {
11
10
  if (typeof req.body === 'object') {
@@ -51,9 +51,6 @@ module.exports = adapter => {
51
51
  let { operation, args } = req._query.SELECT?.from.ref?.slice(-1)[0] || {}
52
52
  if (!operation) return next() //> create or read
53
53
 
54
- // REVISIT: should not be necessary
55
- const _originalQuery = JSON.parse(JSON.stringify(req._query))
56
-
57
54
  // unbound vs. bound
58
55
  let entity, params
59
56
  if (service.model.definitions[operation]) {
@@ -102,7 +99,6 @@ module.exports = adapter => {
102
99
 
103
100
  if (operation.returns._type?.match?.(/^cds\./)) {
104
101
  const context = `${'../'.repeat(query?.SELECT?.from?.ref?.length)}$metadata#${cds2edm[operation.returns._type]}`
105
-
106
102
  result = { '@odata.context': context, value: result }
107
103
  return res.send(result)
108
104
  }
@@ -121,15 +117,19 @@ module.exports = adapter => {
121
117
  if (operation.returns.type !== 'sap.esh.SearchResult') {
122
118
  const isCollection = !!operation.returns.items
123
119
  const _target = operation.returns.items ?? operation.returns
124
- // REVISIT: when is edmName needed?
125
- const edmName = _opResultName({ service, operation, returnType: _target })
126
- const metadata = getODataMetadata(
127
- { SELECT: { from: _originalQuery?.SELECT?.from, one: !isCollection }, _target },
128
-
129
- { result, isCollection, edmName }
130
- )
120
+ const options = { result, isCollection }
121
+ if (!_target.name) {
122
+ // case: return inline type def
123
+ options.edmName = _opResultName({ service, operation, returnType: _target })
124
+ }
125
+ const SELECT = {
126
+ from: query ? { ref: [...query.SELECT.from.ref, { operation: operation.name }] } : {},
127
+ one: !isCollection
128
+ }
129
+ const metadata = getODataMetadata({ SELECT, _target }, options)
131
130
  result = getODataResult(result, metadata, { isCollection })
132
131
  }
132
+
133
133
  res.send(result)
134
134
  })
135
135
  .catch(err => {
@@ -402,6 +402,7 @@
402
402
  "$skip=" o val:skip { _setLimitOffset(val) } /
403
403
  "$search=" o s:search_expand { if (s) SELECT.search = s } /
404
404
  "$count=" o count /
405
+ "$apply=" &{ return cds.env.features.skip_apply_parsing } o [^&]* { return null } /
405
406
  "$apply=" o trafos:transformations { return trafos } /
406
407
  // Workaround to support empty expand even if not OData compliant old adapter supported it and did not crash
407
408
  "$expand=" {return null}
@@ -486,7 +487,11 @@
486
487
  / o // Do not add search property for space only
487
488
 
488
489
  search_clause
489
- = val:$( [^&]+ ) { return [{ val }] }
490
+ = val:$(($[ ]* (
491
+ [^"&]+
492
+ / ('"' ("\\\\" / "\\\"" / [^"])+ '"' [ ]*)+
493
+ ))+)
494
+ { return [{ val }] }
490
495
 
491
496
  search_expand
492
497
  = val:$( [^;)]+ ) { return [{ val }] }