@budibase/shared-core 0.0.1

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 (59) hide show
  1. package/LICENSE +7 -0
  2. package/dist/cjs/package.json +30 -0
  3. package/dist/cjs/src/constants.d.ts +68 -0
  4. package/dist/cjs/src/constants.js +72 -0
  5. package/dist/cjs/src/constants.js.map +1 -0
  6. package/dist/cjs/src/filters.d.ts +93 -0
  7. package/dist/cjs/src/filters.js +355 -0
  8. package/dist/cjs/src/filters.js.map +1 -0
  9. package/dist/cjs/src/helpers/helpers.d.ts +13 -0
  10. package/dist/cjs/src/helpers/helpers.js +28 -0
  11. package/dist/cjs/src/helpers/helpers.js.map +1 -0
  12. package/dist/cjs/src/helpers/index.d.ts +2 -0
  13. package/dist/cjs/src/helpers/index.js +19 -0
  14. package/dist/cjs/src/helpers/index.js.map +1 -0
  15. package/dist/cjs/src/helpers/integrations.d.ts +2 -0
  16. package/dist/cjs/src/helpers/integrations.js +9 -0
  17. package/dist/cjs/src/helpers/integrations.js.map +1 -0
  18. package/dist/cjs/src/index.d.ts +4 -0
  19. package/dist/cjs/src/index.js +34 -0
  20. package/dist/cjs/src/index.js.map +1 -0
  21. package/dist/cjs/src/utils.d.ts +1 -0
  22. package/dist/cjs/src/utils.js +8 -0
  23. package/dist/cjs/src/utils.js.map +1 -0
  24. package/dist/cjs/tsconfig-cjs.build.tsbuildinfo +1 -0
  25. package/dist/mjs/package.json +30 -0
  26. package/dist/mjs/src/constants.d.ts +68 -0
  27. package/dist/mjs/src/constants.js +69 -0
  28. package/dist/mjs/src/constants.js.map +1 -0
  29. package/dist/mjs/src/filters.d.ts +93 -0
  30. package/dist/mjs/src/filters.js +345 -0
  31. package/dist/mjs/src/filters.js.map +1 -0
  32. package/dist/mjs/src/helpers/helpers.d.ts +13 -0
  33. package/dist/mjs/src/helpers/helpers.js +24 -0
  34. package/dist/mjs/src/helpers/helpers.js.map +1 -0
  35. package/dist/mjs/src/helpers/index.d.ts +2 -0
  36. package/dist/mjs/src/helpers/index.js +3 -0
  37. package/dist/mjs/src/helpers/index.js.map +1 -0
  38. package/dist/mjs/src/helpers/integrations.d.ts +2 -0
  39. package/dist/mjs/src/helpers/integrations.js +5 -0
  40. package/dist/mjs/src/helpers/integrations.js.map +1 -0
  41. package/dist/mjs/src/index.d.ts +4 -0
  42. package/dist/mjs/src/index.js +5 -0
  43. package/dist/mjs/src/index.js.map +1 -0
  44. package/dist/mjs/src/utils.d.ts +1 -0
  45. package/dist/mjs/src/utils.js +4 -0
  46. package/dist/mjs/src/utils.js.map +1 -0
  47. package/dist/mjs/tsconfig.build.tsbuildinfo +1 -0
  48. package/package.json +31 -0
  49. package/src/constants.ts +69 -0
  50. package/src/filters.ts +462 -0
  51. package/src/helpers/helpers.ts +23 -0
  52. package/src/helpers/index.ts +2 -0
  53. package/src/helpers/integrations.ts +5 -0
  54. package/src/index.ts +4 -0
  55. package/src/utils.ts +6 -0
  56. package/tsconfig-base.build.json +25 -0
  57. package/tsconfig-cjs.build.json +8 -0
  58. package/tsconfig.build.json +8 -0
  59. package/tsconfig.json +10 -0
@@ -0,0 +1,69 @@
1
+ export const OperatorOptions = {
2
+ Equals: {
3
+ value: "equal",
4
+ label: "Equals",
5
+ },
6
+ NotEquals: {
7
+ value: "notEqual",
8
+ label: "Not equals",
9
+ },
10
+ Empty: {
11
+ value: "empty",
12
+ label: "Is empty",
13
+ },
14
+ NotEmpty: {
15
+ value: "notEmpty",
16
+ label: "Is not empty",
17
+ },
18
+ StartsWith: {
19
+ value: "string",
20
+ label: "Starts with",
21
+ },
22
+ Like: {
23
+ value: "fuzzy",
24
+ label: "Like",
25
+ },
26
+ MoreThan: {
27
+ value: "rangeLow",
28
+ label: "More than or equal to",
29
+ },
30
+ LessThan: {
31
+ value: "rangeHigh",
32
+ label: "Less than or equal to",
33
+ },
34
+ Contains: {
35
+ value: "contains",
36
+ label: "Contains",
37
+ },
38
+ NotContains: {
39
+ value: "notContains",
40
+ label: "Does not contain",
41
+ },
42
+ In: {
43
+ value: "oneOf",
44
+ label: "Is in",
45
+ },
46
+ ContainsAny: {
47
+ value: "containsAny",
48
+ label: "Has any",
49
+ },
50
+ }
51
+
52
+ export const SqlNumberTypeRangeMap = {
53
+ integer: {
54
+ max: 2147483647,
55
+ min: -2147483648,
56
+ },
57
+ int: {
58
+ max: 2147483647,
59
+ min: -2147483648,
60
+ },
61
+ smallint: {
62
+ max: 32767,
63
+ min: -32768,
64
+ },
65
+ mediumint: {
66
+ max: 8388607,
67
+ min: -8388608,
68
+ },
69
+ }
package/src/filters.ts ADDED
@@ -0,0 +1,462 @@
1
+ import { Datasource, FieldType, SortDirection, SortType } from "@budibase/types"
2
+ import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
3
+ import { deepGet } from "./helpers"
4
+
5
+ const HBS_REGEX = /{{([^{].*?)}}/g
6
+
7
+ /**
8
+ * Returns the valid operator options for a certain data type
9
+ * @param type the data type
10
+ */
11
+ export const getValidOperatorsForType = (
12
+ type: FieldType,
13
+ field: string,
14
+ datasource: Datasource & { tableId: any } // TODO: is this table id ever populated?
15
+ ) => {
16
+ const Op = OperatorOptions
17
+ const stringOps = [
18
+ Op.Equals,
19
+ Op.NotEquals,
20
+ Op.StartsWith,
21
+ Op.Like,
22
+ Op.Empty,
23
+ Op.NotEmpty,
24
+ Op.In,
25
+ ]
26
+ const numOps = [
27
+ Op.Equals,
28
+ Op.NotEquals,
29
+ Op.MoreThan,
30
+ Op.LessThan,
31
+ Op.Empty,
32
+ Op.NotEmpty,
33
+ Op.In,
34
+ ]
35
+ let ops: {
36
+ value: string
37
+ label: string
38
+ }[] = []
39
+ if (type === "string") {
40
+ ops = stringOps
41
+ } else if (type === "number") {
42
+ ops = numOps
43
+ } else if (type === "options") {
44
+ ops = [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty, Op.In]
45
+ } else if (type === "array") {
46
+ ops = [Op.Contains, Op.NotContains, Op.Empty, Op.NotEmpty, Op.ContainsAny]
47
+ } else if (type === "boolean") {
48
+ ops = [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
49
+ } else if (type === "longform") {
50
+ ops = stringOps
51
+ } else if (type === "datetime") {
52
+ ops = numOps
53
+ } else if (type === "formula") {
54
+ ops = stringOps.concat([Op.MoreThan, Op.LessThan])
55
+ }
56
+
57
+ // Filter out "like" for internal tables
58
+ const externalTable = datasource?.tableId?.includes("datasource_plus")
59
+ if (datasource?.type === "table" && !externalTable) {
60
+ ops = ops.filter(x => x !== Op.Like)
61
+ }
62
+
63
+ // Only allow equal/not equal for _id in SQL tables
64
+ if (field === "_id" && externalTable) {
65
+ ops = [Op.Equals, Op.NotEquals, Op.In]
66
+ }
67
+
68
+ return ops
69
+ }
70
+
71
+ /**
72
+ * Operators which do not support empty strings as values
73
+ */
74
+ export const NoEmptyFilterStrings = [
75
+ OperatorOptions.StartsWith.value,
76
+ OperatorOptions.Like.value,
77
+ OperatorOptions.Equals.value,
78
+ OperatorOptions.NotEquals.value,
79
+ OperatorOptions.Contains.value,
80
+ OperatorOptions.NotContains.value,
81
+ ] as (keyof QueryFields)[]
82
+
83
+ /**
84
+ * Removes any fields that contain empty strings that would cause inconsistent
85
+ * behaviour with how backend tables are filtered (no value means no filter).
86
+ */
87
+ const cleanupQuery = (query: Query) => {
88
+ if (!query) {
89
+ return query
90
+ }
91
+ for (let filterField of NoEmptyFilterStrings) {
92
+ if (!query[filterField]) {
93
+ continue
94
+ }
95
+
96
+ for (let [key, value] of Object.entries(query[filterField]!)) {
97
+ if (value == null || value === "") {
98
+ delete query[filterField]![key]
99
+ }
100
+ }
101
+ }
102
+ return query
103
+ }
104
+
105
+ /**
106
+ * Removes a numeric prefix on field names designed to give fields uniqueness
107
+ */
108
+ const removeKeyNumbering = (key: string) => {
109
+ if (typeof key === "string" && key.match(/\d[0-9]*:/g) != null) {
110
+ const parts = key.split(":")
111
+ parts.shift()
112
+ return parts.join(":")
113
+ } else {
114
+ return key
115
+ }
116
+ }
117
+
118
+ type Filter = {
119
+ operator: keyof Query
120
+ field: string
121
+ type: any
122
+ value: any
123
+ externalType: keyof typeof SqlNumberTypeRangeMap
124
+ }
125
+
126
+ type Query = QueryFields & QueryConfig
127
+ type QueryFields = {
128
+ string?: {
129
+ [key: string]: string
130
+ }
131
+ fuzzy?: {
132
+ [key: string]: string
133
+ }
134
+ range?: {
135
+ [key: string]: {
136
+ high: number | string
137
+ low: number | string
138
+ }
139
+ }
140
+ equal?: {
141
+ [key: string]: any
142
+ }
143
+ notEqual?: {
144
+ [key: string]: any
145
+ }
146
+ empty?: {
147
+ [key: string]: any
148
+ }
149
+ notEmpty?: {
150
+ [key: string]: any
151
+ }
152
+ oneOf?: {
153
+ [key: string]: any[]
154
+ }
155
+ contains?: {
156
+ [key: string]: any[]
157
+ }
158
+ notContains?: {
159
+ [key: string]: any[]
160
+ }
161
+ containsAny?: {
162
+ [key: string]: any[]
163
+ }
164
+ }
165
+
166
+ type QueryConfig = {
167
+ allOr?: boolean
168
+ }
169
+
170
+ type QueryFieldsType = keyof QueryFields
171
+
172
+ /**
173
+ * Builds a lucene JSON query from the filter structure generated in the builder
174
+ * @param filter the builder filter structure
175
+ */
176
+ export const buildLuceneQuery = (filter: Filter[]) => {
177
+ let query: Query = {
178
+ string: {},
179
+ fuzzy: {},
180
+ range: {},
181
+ equal: {},
182
+ notEqual: {},
183
+ empty: {},
184
+ notEmpty: {},
185
+ contains: {},
186
+ notContains: {},
187
+ oneOf: {},
188
+ containsAny: {},
189
+ }
190
+ if (Array.isArray(filter)) {
191
+ filter.forEach(expression => {
192
+ let { operator, field, type, value, externalType } = expression
193
+ const isHbs =
194
+ typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0
195
+ // Parse all values into correct types
196
+ if (operator === "allOr") {
197
+ query.allOr = true
198
+ return
199
+ }
200
+ if (
201
+ type === "datetime" &&
202
+ !isHbs &&
203
+ operator !== "empty" &&
204
+ operator !== "notEmpty"
205
+ ) {
206
+ // Ensure date value is a valid date and parse into correct format
207
+ if (!value) {
208
+ return
209
+ }
210
+ try {
211
+ value = new Date(value).toISOString()
212
+ } catch (error) {
213
+ return
214
+ }
215
+ }
216
+ if (type === "number" && typeof value === "string") {
217
+ if (operator === "oneOf") {
218
+ value = value.split(",").map(item => parseFloat(item))
219
+ } else if (!isHbs) {
220
+ value = parseFloat(value)
221
+ }
222
+ }
223
+ if (type === "boolean") {
224
+ value = `${value}`?.toLowerCase() === "true"
225
+ }
226
+ if (
227
+ ["contains", "notContains", "containsAny"].includes(operator) &&
228
+ type === "array" &&
229
+ typeof value === "string"
230
+ ) {
231
+ value = value.split(",")
232
+ }
233
+ if (operator.startsWith("range") && query.range) {
234
+ const minint =
235
+ SqlNumberTypeRangeMap[externalType]?.min || Number.MIN_SAFE_INTEGER
236
+ const maxint =
237
+ SqlNumberTypeRangeMap[externalType]?.max || Number.MAX_SAFE_INTEGER
238
+ if (!query.range[field]) {
239
+ query.range[field] = {
240
+ low: type === "number" ? minint : "0000-00-00T00:00:00.000Z",
241
+ high: type === "number" ? maxint : "9999-00-00T00:00:00.000Z",
242
+ }
243
+ }
244
+ if ((operator as any) === "rangeLow" && value != null && value !== "") {
245
+ query.range[field].low = value
246
+ } else if (
247
+ (operator as any) === "rangeHigh" &&
248
+ value != null &&
249
+ value !== ""
250
+ ) {
251
+ query.range[field].high = value
252
+ }
253
+ } else if (query[operator]) {
254
+ if (type === "boolean") {
255
+ // Transform boolean filters to cope with null.
256
+ // "equals false" needs to be "not equals true"
257
+ // "not equals false" needs to be "equals true"
258
+ if (operator === "equal" && value === false) {
259
+ query.notEqual = query.notEqual || {}
260
+ query.notEqual[field] = true
261
+ } else if (operator === "notEqual" && value === false) {
262
+ query.equal = query.equal || {}
263
+ query.equal[field] = true
264
+ } else {
265
+ query[operator] = query[operator] || {}
266
+ query[operator]![field] = value
267
+ }
268
+ } else {
269
+ query[operator] = query[operator] || {}
270
+ query[operator]![field] = value
271
+ }
272
+ }
273
+ })
274
+ }
275
+ return query
276
+ }
277
+
278
+ /**
279
+ * Performs a client-side lucene search on an array of data
280
+ * @param docs the data
281
+ * @param query the JSON lucene query
282
+ */
283
+ export const runLuceneQuery = (docs: any[], query?: Query) => {
284
+ if (!docs || !Array.isArray(docs)) {
285
+ return []
286
+ }
287
+ if (!query) {
288
+ return docs
289
+ }
290
+
291
+ // Make query consistent first
292
+ query = cleanupQuery(query)
293
+
294
+ // Iterates over a set of filters and evaluates a fail function against a doc
295
+ const match =
296
+ (
297
+ type: QueryFieldsType,
298
+ failFn: (docValue: any, testValue: any) => boolean
299
+ ) =>
300
+ (doc: any) => {
301
+ const filters = Object.entries(query![type] || {})
302
+ for (let i = 0; i < filters.length; i++) {
303
+ const [key, testValue] = filters[i]
304
+ const docValue = deepGet(doc, removeKeyNumbering(key))
305
+ if (failFn(docValue, testValue)) {
306
+ return false
307
+ }
308
+ }
309
+ return true
310
+ }
311
+
312
+ // Process a string match (fails if the value does not start with the string)
313
+ const stringMatch = match("string", (docValue: string, testValue: string) => {
314
+ return (
315
+ !docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
316
+ )
317
+ })
318
+
319
+ // Process a fuzzy match (treat the same as starts with when running locally)
320
+ const fuzzyMatch = match("fuzzy", (docValue: string, testValue: string) => {
321
+ return (
322
+ !docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
323
+ )
324
+ })
325
+
326
+ // Process a range match
327
+ const rangeMatch = match(
328
+ "range",
329
+ (
330
+ docValue: string | number | null,
331
+ testValue: { low: number; high: number }
332
+ ) => {
333
+ return (
334
+ docValue == null ||
335
+ docValue === "" ||
336
+ docValue < testValue.low ||
337
+ docValue > testValue.high
338
+ )
339
+ }
340
+ )
341
+
342
+ // Process an equal match (fails if the value is different)
343
+ const equalMatch = match(
344
+ "equal",
345
+ (docValue: any, testValue: string | null) => {
346
+ return testValue != null && testValue !== "" && docValue !== testValue
347
+ }
348
+ )
349
+
350
+ // Process a not-equal match (fails if the value is the same)
351
+ const notEqualMatch = match(
352
+ "notEqual",
353
+ (docValue: any, testValue: string | null) => {
354
+ return testValue != null && testValue !== "" && docValue === testValue
355
+ }
356
+ )
357
+
358
+ // Process an empty match (fails if the value is not empty)
359
+ const emptyMatch = match("empty", (docValue: string | null) => {
360
+ return docValue != null && docValue !== ""
361
+ })
362
+
363
+ // Process a not-empty match (fails is the value is empty)
364
+ const notEmptyMatch = match("notEmpty", (docValue: string | null) => {
365
+ return docValue == null || docValue === ""
366
+ })
367
+
368
+ // Process an includes match (fails if the value is not included)
369
+ const oneOf = match("oneOf", (docValue: any, testValue: any) => {
370
+ if (typeof testValue === "string") {
371
+ testValue = testValue.split(",")
372
+ if (typeof docValue === "number") {
373
+ testValue = testValue.map((item: string) => parseFloat(item))
374
+ }
375
+ }
376
+ return !testValue?.includes(docValue)
377
+ })
378
+
379
+ const containsAny = match("containsAny", (docValue: any, testValue: any) => {
380
+ return !docValue?.includes(...testValue)
381
+ })
382
+
383
+ const contains = match(
384
+ "contains",
385
+ (docValue: string | any[], testValue: any[]) => {
386
+ return !testValue?.every((item: any) => docValue?.includes(item))
387
+ }
388
+ )
389
+
390
+ const notContains = match(
391
+ "notContains",
392
+ (docValue: string | any[], testValue: any[]) => {
393
+ return testValue?.every((item: any) => docValue?.includes(item))
394
+ }
395
+ )
396
+
397
+ // Match a document against all criteria
398
+ const docMatch = (doc: any) => {
399
+ return (
400
+ stringMatch(doc) &&
401
+ fuzzyMatch(doc) &&
402
+ rangeMatch(doc) &&
403
+ equalMatch(doc) &&
404
+ notEqualMatch(doc) &&
405
+ emptyMatch(doc) &&
406
+ notEmptyMatch(doc) &&
407
+ oneOf(doc) &&
408
+ contains(doc) &&
409
+ containsAny(doc) &&
410
+ notContains(doc)
411
+ )
412
+ }
413
+
414
+ // Process all docs
415
+ return docs.filter(docMatch)
416
+ }
417
+
418
+ /**
419
+ * Performs a client-side sort from the equivalent server-side lucene sort
420
+ * parameters.
421
+ * @param docs the data
422
+ * @param sort the sort column
423
+ * @param sortOrder the sort order ("ascending" or "descending")
424
+ * @param sortType the type of sort ("string" or "number")
425
+ */
426
+ export const luceneSort = (
427
+ docs: any[],
428
+ sort: string,
429
+ sortOrder: SortDirection,
430
+ sortType = SortType.STRING
431
+ ) => {
432
+ if (!sort || !sortOrder || !sortType) {
433
+ return docs
434
+ }
435
+ const parse =
436
+ sortType === "string" ? (x: any) => `${x}` : (x: string) => parseFloat(x)
437
+ return docs
438
+ .slice()
439
+ .sort((a: { [x: string]: any }, b: { [x: string]: any }) => {
440
+ const colA = parse(a[sort])
441
+ const colB = parse(b[sort])
442
+ if (sortOrder.toLowerCase() === "descending") {
443
+ return colA > colB ? -1 : 1
444
+ } else {
445
+ return colA > colB ? 1 : -1
446
+ }
447
+ })
448
+ }
449
+
450
+ /**
451
+ * Limits the specified docs to the specified number of rows from the equivalent
452
+ * server-side lucene limit parameters.
453
+ * @param docs the data
454
+ * @param limit the number of docs to limit to
455
+ */
456
+ export const luceneLimit = (docs: any[], limit: string) => {
457
+ const numLimit = parseFloat(limit)
458
+ if (isNaN(numLimit)) {
459
+ return docs
460
+ }
461
+ return docs.slice(0, numLimit)
462
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Gets a key within an object. The key supports dot syntax for retrieving deep
3
+ * fields - e.g. "a.b.c".
4
+ * Exact matches of keys with dots in them take precedence over nested keys of
5
+ * the same path - e.g. getting "a.b" from { "a.b": "foo", a: { b: "bar" } }
6
+ * will return "foo" over "bar".
7
+ * @param obj the object
8
+ * @param key the key
9
+ * @return {*|null} the value or null if a value was not found for this key
10
+ */
11
+ export const deepGet = (obj: { [x: string]: any }, key: string) => {
12
+ if (!obj || !key) {
13
+ return null
14
+ }
15
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
16
+ return obj[key]
17
+ }
18
+ const split = key.split(".")
19
+ for (let i = 0; i < split.length; i++) {
20
+ obj = obj?.[split[i]]
21
+ }
22
+ return obj
23
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./helpers"
2
+ export * from "./integrations"
@@ -0,0 +1,5 @@
1
+ import { SourceName } from "@budibase/types"
2
+
3
+ export function isGoogleSheets(type: SourceName) {
4
+ return type === SourceName.GOOGLE_SHEETS
5
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./constants"
2
+ export * as dataFilters from "./filters"
3
+ export * as helpers from "./helpers"
4
+ export * as utils from "./utils"
package/src/utils.ts ADDED
@@ -0,0 +1,6 @@
1
+ export function unreachable(
2
+ value: never,
3
+ message = `No such case in exhaustive switch: ${value}`
4
+ ) {
5
+ throw new Error(message)
6
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es6",
4
+ "moduleResolution": "node",
5
+ "lib": ["es2020"],
6
+ "strict": true,
7
+ "noImplicitAny": true,
8
+ "esModuleInterop": true,
9
+ "resolveJsonModule": true,
10
+ "incremental": true,
11
+ "sourceMap": true,
12
+ "declaration": true,
13
+ "types": ["node"],
14
+ "outDir": "dist",
15
+ "skipLibCheck": true
16
+ },
17
+ "include": ["**/*.js", "**/*.ts", "package.json"],
18
+ "exclude": [
19
+ "node_modules",
20
+ "dist",
21
+ "**/*.spec.ts",
22
+ "**/*.spec.js",
23
+ "__mocks__"
24
+ ]
25
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig-base.build.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "outDir": "dist/cjs",
6
+ "target": "es2015"
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig-base.build.json",
3
+ "compilerOptions": {
4
+ "module": "esnext",
5
+ "outDir": "dist/mjs",
6
+ "target": "esnext"
7
+ }
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.build.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "baseUrl": ".",
6
+ "paths": {
7
+ "@budibase/types": ["../types/src"]
8
+ }
9
+ }
10
+ }