@labdigital/commercetools-mock 0.7.0 → 0.9.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.
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.0",
2
+ "version": "0.9.0",
3
3
  "license": "MIT",
4
4
  "main": "dist/index.js",
5
5
  "typings": "dist/index.d.ts",
@@ -7,16 +7,15 @@
7
7
  "dist",
8
8
  "src"
9
9
  ],
10
+ "exports": {
11
+ ".": {
12
+ "require": "./dist/index.js",
13
+ "import": "./dist/index.mjs"
14
+ }
15
+ },
10
16
  "engines": {
11
17
  "node": ">=14"
12
18
  },
13
- "scripts": {
14
- "start": "nodemon --watch src --exec 'node -r esbuild-register' src/server.ts",
15
- "generate": "node -r esbuild-register generate-validation.ts",
16
- "build": "tsup",
17
- "test": "jest test --coverage",
18
- "lint": "eslint"
19
- },
20
19
  "prettier": {
21
20
  "printWidth": 80,
22
21
  "semi": false,
@@ -57,8 +56,6 @@
57
56
  },
58
57
  "dependencies": {
59
58
  "@types/lodash": "^4.14.182",
60
- "ajv": "^8.8.2",
61
- "ajv-formats": "^2.1.1",
62
59
  "basic-auth": "^2.0.1",
63
60
  "body-parser": "^1.20.0",
64
61
  "deep-equal": "^2.0.5",
@@ -73,5 +70,11 @@
73
70
  },
74
71
  "peerDependencies": {
75
72
  "@commercetools/platform-sdk": "^2.4.1"
73
+ },
74
+ "scripts": {
75
+ "start": "nodemon --watch src --exec 'node -r esbuild-register' src/server.ts",
76
+ "build": "tsup",
77
+ "test": "jest test --coverage",
78
+ "lint": "eslint"
76
79
  }
77
- }
80
+ }
package/src/helpers.ts CHANGED
@@ -11,6 +11,30 @@ export const getBaseResourceProperties = () => {
11
11
  }
12
12
  }
13
13
 
14
+ /**
15
+ * Do a nested lookup by using a path. For example `foo.bar.value` will
16
+ * return obj['foo']['bar']['value']
17
+ */
18
+ export const nestedLookup = (obj: any, path: string): any => {
19
+ if (!path || path === '') {
20
+ return obj
21
+ }
22
+
23
+ const parts = path.split('.')
24
+ let val = obj
25
+
26
+ for (let i = 0; i < parts.length; i++) {
27
+ const part = parts[i]
28
+ if (val == undefined) {
29
+ return undefined
30
+ }
31
+
32
+ val = val[part]
33
+ }
34
+
35
+ return val
36
+ }
37
+
14
38
  export const QueryParamsAsArray = (
15
39
  input: string | ParsedQs | string[] | ParsedQs[] | undefined
16
40
  ): string[] => {
@@ -127,6 +127,12 @@ describe('Search filter', () => {
127
127
  ).toBeTruthy()
128
128
  })
129
129
 
130
+ test('by price range - or', async () => {
131
+ expect(
132
+ match(`variants.price.centAmount:range (2 TO 1500 ), (1500 TO 3000), (3000 TO 6000)`).isMatch
133
+ ).toBeTruthy()
134
+ })
135
+
130
136
  test('by scopedPrice range', async () => {
131
137
  let result
132
138
  let products: Product[]
@@ -5,6 +5,7 @@
5
5
  import { Product, ProductVariant } from '@commercetools/platform-sdk'
6
6
  import perplex from 'perplex'
7
7
  import Parser from 'pratt'
8
+ import { nestedLookup } from '../helpers'
8
9
  import { Writable } from '../types'
9
10
 
10
11
  type MatchFunc = (target: any) => boolean
@@ -14,6 +15,43 @@ type ProductFilter = (
14
15
  markMatchingVariants: boolean
15
16
  ) => boolean
16
17
 
18
+ type Symbol = {
19
+ type: 'Symbol'
20
+ kind: 'int' | 'string' | 'any'
21
+ value: any
22
+ }
23
+
24
+ type RangeExpressionSet = {
25
+ source: string
26
+ type: 'RangeExpression'
27
+ children?: RangeExpression[]
28
+ }
29
+
30
+ type FilterExpressionSet = {
31
+ source: string
32
+ type: 'FilterExpression'
33
+ children?: FilterExpression[]
34
+ }
35
+
36
+ type TermExpressionSet = {
37
+ source: string
38
+ type: 'TermExpression'
39
+ }
40
+
41
+ type ExpressionSet = RangeExpressionSet | FilterExpressionSet | TermExpressionSet
42
+
43
+ export type RangeExpression = {
44
+ type: 'RangeExpression'
45
+ start?: number
46
+ stop?: number
47
+ match: (obj: any) => boolean
48
+ }
49
+
50
+ export type FilterExpression = {
51
+ type: 'FilterExpression'
52
+ match: (obj: any) => boolean
53
+ }
54
+
17
55
  /**
18
56
  * Returns a function (ProductFilter).
19
57
  * NOTE: The filter can alter the resources in-place (FIXME)
@@ -53,7 +91,7 @@ const getLexer = (value: string) => {
53
91
  .token('WS', /\s+/, true) // skip
54
92
  }
55
93
 
56
- const generateMatchFunc = (filter: string): MatchFunc => {
94
+ const parseFilter = (filter: string): ExpressionSet => {
57
95
  const lexer = getLexer(filter)
58
96
  const parser = new Parser(lexer)
59
97
  .builder()
@@ -61,42 +99,81 @@ const generateMatchFunc = (filter: string): MatchFunc => {
61
99
  return t.token.match
62
100
  })
63
101
  .led(':', 100, ({ left, bp }) => {
64
- const expr = parser.parse({ terminals: [bp - 1] })
102
+ let parsed: any = parser.parse({ terminals: [bp - 1] })
103
+ let expressions: RangeExpression[] | FilterExpression[] | Symbol[]
104
+ expressions = !Array.isArray(parsed) ? [parsed] : parsed
65
105
 
66
- if (Array.isArray(expr)) {
67
- return (obj: any): boolean => {
68
- return expr.includes(obj)
69
- }
106
+ // Make sure we only have one type of expression (cannot mix)
107
+ const unique = new Set(expressions.map(expr => expr.type))
108
+ if (unique.size > 1) {
109
+ throw new Error('Invalid expression')
70
110
  }
71
- if (typeof expr === 'function') {
72
- return (obj: any): boolean => {
73
- return expr(obj)
111
+
112
+ // Convert plain symbols to a filter expression. For example
113
+ // variants.attribute.foobar:4 where 4 is a Symbol should result
114
+ // in a comparison
115
+ if (expressions.some(expr => expr.type == 'Symbol')) {
116
+ return {
117
+ source: left as string,
118
+ type: 'FilterExpression',
119
+ children: expressions.map((e): FilterExpression => {
120
+ if (e.type != 'Symbol') {
121
+ throw new Error('Invalid expression')
122
+ }
123
+
124
+ return {
125
+ type: 'FilterExpression',
126
+ match: (obj: any): boolean => {
127
+ return obj === e.value
128
+ },
129
+ }
130
+ })
74
131
  }
75
132
  }
76
- return (obj: any): boolean => {
77
- return obj === expr
133
+
134
+ return {
135
+ source: left,
136
+ type: expressions[0].type,
137
+ children: expressions,
78
138
  }
79
139
  })
80
140
  .nud('STRING', 20, t => {
81
- // @ts-ignore
82
- return t.token.groups[1]
141
+ return {
142
+ type: 'Symbol',
143
+ kind: 'string',
144
+ // @ts-ignore
145
+ value: t.token.groups[1],
146
+ } as Symbol
83
147
  })
84
148
  .nud('INT', 5, t => {
85
- // @ts-ignore
86
- return parseInt(t.token.match, 10)
149
+ return {
150
+ type: 'Symbol',
151
+ kind: 'int',
152
+ value: parseInt(t.token.match, 10),
153
+ } as Symbol
87
154
  })
88
155
  .nud('STAR', 5, t => {
89
- return null
156
+ return {
157
+ type: 'Symbol',
158
+ kind: 'any',
159
+ value: null,
160
+ }
90
161
  })
91
162
  .nud('EXISTS', 10, ({ bp }) => {
92
- return (val: any) => {
93
- return val !== undefined
94
- }
163
+ return {
164
+ type: 'FilterExpression',
165
+ match: (obj: any): boolean => {
166
+ return obj !== undefined
167
+ },
168
+ } as FilterExpression
95
169
  })
96
170
  .nud('MISSING', 10, ({ bp }) => {
97
- return (val: any) => {
98
- return val === undefined
99
- }
171
+ return {
172
+ type: 'FilterExpression',
173
+ match: (obj: any): boolean => {
174
+ return obj === undefined
175
+ },
176
+ } as FilterExpression
100
177
  })
101
178
  .led('COMMA', 200, ({ left, token, bp }) => {
102
179
  const expr: any = parser.parse({ terminals: [bp - 1] })
@@ -106,52 +183,92 @@ const generateMatchFunc = (filter: string): MatchFunc => {
106
183
  return [left, expr]
107
184
  }
108
185
  })
186
+ .nud('(', 100, t => {
187
+ const expr: any = parser.parse({ terminals: [')'] })
188
+ lexer.expect(')')
189
+ return expr
190
+ })
109
191
  .bp(')', 0)
110
192
  .led('TO', 20, ({ left, bp }) => {
111
193
  const expr: any = parser.parse({ terminals: [bp - 1] })
112
- return [left, expr]
194
+ return {
195
+ start: left.value,
196
+ stop: expr.value,
197
+ }
113
198
  })
114
199
  .nud('RANGE', 20, ({ bp }) => {
115
- lexer.next() // Skip over opening parthensis
116
- const [start, stop] = parser.parse()
117
- console.log(start, stop)
118
- if (start !== null && stop !== null ) {
119
- return (obj: any): boolean => {
120
- return obj >= start && obj <= stop
121
- }
122
- }
123
- else if (start === null && stop !== null) {
124
- return (obj: any): boolean => {
125
- return obj <= stop
126
- }
200
+ let ranges: any = parser.parse()
201
+
202
+ // If multiple ranges are defined we receive an array of ranges. So let's
203
+ // make sure we always have an array
204
+ if (!Array.isArray(ranges)) {
205
+ ranges = [ranges]
127
206
  }
128
- else if (start !== null && stop === null) {
129
- return (obj: any): boolean => {
130
- return obj >= start
131
- }
132
- } else {
133
- return (obj: any): boolean => {
134
- return true
207
+
208
+ // Return a list of functions which matches the ranges. These functions
209
+ // are processed as an OR clause
210
+ return ranges.map((range: any) => {
211
+ let func = undefined
212
+
213
+ if (range.start !== null && range.stop !== null) {
214
+ func = (obj: any): boolean => {
215
+ return obj >= range.start && obj <= range.stop
216
+ }
217
+ } else if (range.start === null && range.stop !== null) {
218
+ func = (obj: any): boolean => {
219
+ return obj <= range.stop
220
+ }
221
+ } else if (range.start !== null && range.stop === null) {
222
+ func = (obj: any): boolean => {
223
+ return obj >= range.start
224
+ }
225
+ } else {
226
+ func = (obj: any): boolean => {
227
+ return true
228
+ }
135
229
  }
136
230
 
137
- }
231
+ return {
232
+ type: 'RangeExpression',
233
+ start: range.start,
234
+ stop: range.stop,
235
+ match: func,
236
+ } as RangeExpression
237
+ })
138
238
  })
139
239
  .build()
140
240
 
141
- const result = parser.parse()
241
+ return parser.parse()
242
+ }
142
243
 
143
- if (typeof result !== 'function') {
244
+ const generateMatchFunc = (filter: string) => {
245
+ const result = parseFilter(filter)
246
+ if (!result) {
144
247
  const lines = filter.split('\n')
145
248
  const column = lines[lines.length - 1].length
146
249
  throw new Error(`Syntax error while parsing '${filter}'.`)
147
250
  }
148
- return result
251
+ if (result.type == 'TermExpression') {
252
+ throw new Error(`Syntax error while parsing '${filter}'.`)
253
+ }
254
+
255
+ return (obj: any) => {
256
+ if (!result.children) return false
257
+ return result.children.some(c => c.match(obj))
258
+ }
149
259
  }
150
260
 
151
- const filterProduct = (
152
- source: string,
153
- exprFunc: MatchFunc
154
- ): ProductFilter => {
261
+ export const generateFacetFunc = (filter: string): ExpressionSet => {
262
+ if (!filter.includes(':')) {
263
+ return {
264
+ source: filter,
265
+ type: 'TermExpression',
266
+ }
267
+ }
268
+ return parseFilter(filter)
269
+ }
270
+
271
+ const filterProduct = (source: string, exprFunc: MatchFunc): ProductFilter => {
155
272
  return (p: Product, markMatchingVariants: boolean): boolean => {
156
273
  const value = nestedLookup(p, source)
157
274
  return exprFunc(value)
@@ -172,13 +289,12 @@ const filterVariants = (
172
289
  const value = resolveVariantValue(variant, path)
173
290
 
174
291
  if (exprFunc(value)) {
175
-
176
292
  // If markMatchingVariants parameter is true those ProductVariants that
177
293
  // match the search query have the additional field isMatchingVariant
178
294
  // set to true. For the other variants in the same product projection
179
295
  // this field is set to false.
180
296
  if (markMatchingVariants) {
181
- variants.forEach(v => v.isMatchingVariant = false)
297
+ variants.forEach(v => (v.isMatchingVariant = false))
182
298
  variant.isMatchingVariant = true
183
299
  }
184
300
  return true
@@ -189,10 +305,13 @@ const filterVariants = (
189
305
  }
190
306
  }
191
307
 
192
- const resolveVariantValue = (obj: ProductVariant, path: string): any => {
308
+ export const resolveVariantValue = (obj: ProductVariant, path: string): any => {
193
309
  if (path === undefined) {
194
310
  return obj
195
311
  }
312
+ if (path.startsWith('variants.')) {
313
+ path = path.substring(path.indexOf('.') + 1)
314
+ }
196
315
 
197
316
  if (path.startsWith('attributes.')) {
198
317
  const [, attrName, ...rest] = path.split('.')
@@ -216,27 +335,7 @@ const resolveVariantValue = (obj: ProductVariant, path: string): any => {
216
335
  return nestedLookup(obj, path)
217
336
  }
218
337
 
219
- const nestedLookup = (obj: any, path: string): any => {
220
- if (!path || path === '') {
221
- return obj
222
- }
223
-
224
- const parts = path.split('.')
225
- let val = obj
226
-
227
- for (let i = 0; i < parts.length; i++) {
228
- const part = parts[i]
229
- if (val == undefined) {
230
- return undefined
231
- }
232
-
233
- val = val[part]
234
- }
235
-
236
- return val
237
- }
238
-
239
- const getVariants = (p: Product, staged: boolean): ProductVariant[] => {
338
+ export const getVariants = (p: Product, staged: boolean): ProductVariant[] => {
240
339
  return [
241
340
  staged
242
341
  ? p.masterData.staged?.masterVariant
@@ -4,9 +4,25 @@ import {
4
4
  Product,
5
5
  ProductProjection,
6
6
  QueryParam,
7
+ FacetResults,
8
+ FacetTerm,
9
+ TermFacetResult,
10
+ RangeFacetResult,
11
+ FilteredFacetResult,
7
12
  } from '@commercetools/platform-sdk'
13
+ import { ByProjectKeyProductProjectionsSearchRequestBuilder } from '@commercetools/platform-sdk/dist/declarations/src/generated/client/search/by-project-key-product-projections-search-request-builder'
14
+ import { nestedLookup } from './helpers'
15
+ import { ProductService } from './services/product'
16
+ import { Writable } from './types'
8
17
  import { CommercetoolsError } from './exceptions'
9
- import { parseFilterExpression } from './lib/projectionSearchFilter'
18
+ import {
19
+ FilterExpression,
20
+ generateFacetFunc,
21
+ getVariants,
22
+ parseFilterExpression,
23
+ RangeExpression,
24
+ resolveVariantValue,
25
+ } from './lib/projectionSearchFilter'
10
26
  import { applyPriceSelector } from './priceSelector'
11
27
  import { AbstractStorage } from './storage'
12
28
 
@@ -68,10 +84,9 @@ export class ProductProjectionSearch {
68
84
  )
69
85
 
70
86
  // Filters can modify the output. So clone the resources first.
71
- resources = resources
72
- .filter(resource =>
73
- filters.every(f => f(resource, markMatchingVariant))
74
- )
87
+ resources = resources.filter(resource =>
88
+ filters.every(f => f(resource, markMatchingVariant))
89
+ )
75
90
  } catch (err) {
76
91
  throw new CommercetoolsError<InvalidInputError>(
77
92
  {
@@ -84,6 +99,7 @@ export class ProductProjectionSearch {
84
99
  }
85
100
 
86
101
  // TODO: Calculate facets
102
+ const facets = this.getFacets(params, resources)
87
103
 
88
104
  // Apply filters post facetting
89
105
  if (params['filter.query']) {
@@ -91,10 +107,10 @@ export class ProductProjectionSearch {
91
107
  const filters = params['filter.query'].map(f =>
92
108
  parseFilterExpression(f, params.staged ?? false)
93
109
  )
94
- resources = resources
95
- .filter(resource =>
96
- filters.every(f => f(resource, markMatchingVariant))
97
- )
110
+
111
+ resources = resources.filter(resource =>
112
+ filters.every(f => f(resource, markMatchingVariant))
113
+ )
98
114
  } catch (err) {
99
115
  throw new CommercetoolsError<InvalidInputError>(
100
116
  {
@@ -127,7 +143,7 @@ export class ProductProjectionSearch {
127
143
  offset: offset,
128
144
  limit: limit,
129
145
  results: resources.map(this.transform),
130
- facets: {},
146
+ facets: facets,
131
147
  }
132
148
  }
133
149
 
@@ -139,6 +155,9 @@ export class ProductProjectionSearch {
139
155
  lastModifiedAt: product.lastModifiedAt,
140
156
  version: product.version,
141
157
  name: obj.name,
158
+ key: product.key,
159
+ description: obj.description,
160
+ metaDescription: obj.metaDescription,
142
161
  slug: obj.slug,
143
162
  categories: obj.categories,
144
163
  masterVariant: obj.masterVariant,
@@ -146,4 +165,185 @@ export class ProductProjectionSearch {
146
165
  productType: product.productType,
147
166
  }
148
167
  }
168
+
169
+ getFacets(
170
+ params: ProductProjectionSearchParams,
171
+ products: Product[]
172
+ ): FacetResults {
173
+ if (!params.facet) return {}
174
+ const staged = false
175
+ const result: FacetResults = {}
176
+
177
+ for (const facet of params.facet) {
178
+ const expression = generateFacetFunc(facet)
179
+
180
+ // Term Facet
181
+ if (expression.type === 'TermExpression') {
182
+ result[facet] = this.termFacet(expression.source, products, staged)
183
+ }
184
+
185
+ // Range Facet
186
+ if (expression.type === 'RangeExpression') {
187
+ result[expression.source] = this.rangeFacet(
188
+ expression.source,
189
+ expression.children,
190
+ products,
191
+ staged
192
+ )
193
+ }
194
+
195
+ // FilteredFacet
196
+ if (expression.type === 'FilterExpression') {
197
+ result[expression.source] = this.filterFacet(
198
+ expression.source,
199
+ expression.children,
200
+ products,
201
+ staged
202
+ )
203
+ }
204
+ }
205
+
206
+ return result
207
+ }
208
+
209
+ /**
210
+ * TODO: This implemention needs the following additional features:
211
+ * - counting products
212
+ * - correct dataType
213
+ */
214
+ termFacet(
215
+ facet: string,
216
+ products: Product[],
217
+ staged: boolean
218
+ ): TermFacetResult {
219
+ const result: Writable<TermFacetResult> = {
220
+ type: 'terms',
221
+ dataType: 'text',
222
+ missing: 0,
223
+ total: 0,
224
+ other: 0,
225
+ terms: [],
226
+ }
227
+ const terms: Record<any, number> = {}
228
+
229
+ if (facet.startsWith('variants.')) {
230
+ products.forEach(p => {
231
+ const variants = getVariants(p, staged)
232
+ variants.forEach(v => {
233
+ result.total++
234
+
235
+ let value = resolveVariantValue(v, facet)
236
+ if (value === undefined) {
237
+ result.missing++
238
+ } else {
239
+ if (typeof value === 'number') {
240
+ value = Number(value).toFixed(1)
241
+ }
242
+ terms[value] = value in terms ? terms[value] + 1 : 1
243
+ }
244
+ })
245
+ })
246
+ } else {
247
+ products.forEach(p => {
248
+ const value = nestedLookup(p, facet)
249
+ result.total++
250
+ if (value === undefined) {
251
+ result.missing++
252
+ } else {
253
+ terms[value] = value in terms ? terms[value] + 1 : 1
254
+ }
255
+ })
256
+ }
257
+ for (const term in terms) {
258
+ result.terms.push({
259
+ term: term as any,
260
+ count: terms[term],
261
+ })
262
+ }
263
+ return result
264
+ }
265
+
266
+ filterFacet(
267
+ source: string,
268
+ filters: FilterExpression[] | undefined,
269
+ products: Product[],
270
+ staged: boolean
271
+ ): FilteredFacetResult {
272
+ let count = 0
273
+ if (source.startsWith('variants.')) {
274
+ for (const p of products) {
275
+ for (const v of getVariants(p, staged)) {
276
+ const val = resolveVariantValue(v, source)
277
+ if (filters?.some(f => f.match(val))) {
278
+ count++
279
+ }
280
+ }
281
+ }
282
+ } else {
283
+ throw new Error('not supported')
284
+ }
285
+
286
+ return {
287
+ type: 'filter',
288
+ count: count,
289
+ }
290
+ }
291
+
292
+ rangeFacet(
293
+ source: string,
294
+ ranges: RangeExpression[] | undefined,
295
+ products: Product[],
296
+ staged: boolean
297
+ ): RangeFacetResult {
298
+ const counts =
299
+ ranges?.map(range => {
300
+ if (source.startsWith('variants.')) {
301
+ const values = []
302
+ for (const p of products) {
303
+ for (const v of getVariants(p, staged)) {
304
+ const val = resolveVariantValue(v, source)
305
+ if (val === undefined) {
306
+ continue
307
+ }
308
+
309
+ if (range.match(val)) {
310
+ values.push(val)
311
+ }
312
+ }
313
+ }
314
+
315
+ const numValues = values.length
316
+ return {
317
+ type: 'double',
318
+ from: range.start || 0,
319
+ fromStr: range.start !== null ? Number(range.start).toFixed(1) : '',
320
+ to: range.stop || 0,
321
+ toStr: range.stop !== null ? Number(range.stop).toFixed(1) : '',
322
+ count: numValues,
323
+ // totalCount: 0,
324
+ total: values.reduce((a, b) => a + b, 0),
325
+ min: numValues > 0 ? Math.min(...values) : 0,
326
+ max: numValues > 0 ? Math.max(...values) : 0,
327
+ mean: numValues > 0 ? mean(values) : 0,
328
+ }
329
+ } else {
330
+ throw new Error('not supported')
331
+ }
332
+ }) || []
333
+ const data: RangeFacetResult = {
334
+ type: 'range',
335
+ // @ts-ignore
336
+ dataType: 'number',
337
+ ranges: counts,
338
+ }
339
+ return data
340
+ }
341
+ }
342
+
343
+ const mean = (arr: number[]) => {
344
+ let total = 0
345
+ for (let i = 0; i < arr.length; i++) {
346
+ total += arr[i]
347
+ }
348
+ return total / arr.length
149
349
  }