@rokkit/data 1.0.0-next.98 → 1.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * ReactiveDataSet — Svelte 5 rune-based reactive wrapper for dataset operations.
3
+ *
4
+ * Use this in Svelte 5 components when you need the pipeline to auto-recompute
5
+ * when source data changes (e.g. live search, CRUD lists).
6
+ *
7
+ * Unlike `DataSet`, which is imperatively pipeline-driven, `ReactiveDataSet` holds
8
+ * reactive configuration ($state) and exposes a `rows` getter backed by $derived —
9
+ * so templates can bind to `ds.rows` directly without calling `.select()`.
10
+ *
11
+ * File must remain `.svelte.js` for Svelte 5 rune compilation.
12
+ */
13
+
14
+ import { executeFused } from './pipeline.js'
15
+ import { deriveColumnDefs } from './infer.js'
16
+
17
+ export class ReactiveDataSet {
18
+ /** @type {any[]} */
19
+ #source = $state([])
20
+
21
+ /** @type {Function|null} */
22
+ #filter = $state(null)
23
+
24
+ /** @type {Array<{fn: Function}>} */
25
+ #transforms = $state([])
26
+
27
+ /** @type {string[]} */
28
+ #sortFields = $state([])
29
+
30
+ /** @type {Array<Object>} Per-column overrides merged into auto-derived column defs. */
31
+ #columnEnhancements = $state([])
32
+
33
+ /** @type {Object} Options forwarded to deriveColumnDefs (language, scanMode, etc.) */
34
+ #columnOptions = $state({})
35
+
36
+ /**
37
+ * @param {any[]} data - Initial source rows.
38
+ */
39
+ constructor(data = []) {
40
+ this.#source = data
41
+ }
42
+
43
+ /**
44
+ * Reactive computed result of filter + transforms + sort.
45
+ * Bind to this in templates instead of calling `.select()`.
46
+ */
47
+ get rows() {
48
+ const ops = []
49
+ if (this.#filter) ops.push({ type: 'filter', fn: this.#filter })
50
+ for (const t of this.#transforms) ops.push({ type: 'map', fn: t.fn })
51
+ if (this.#sortFields.length > 0) ops.push({ type: 'sort', fields: this.#sortFields })
52
+ return executeFused(this.#source, ops)
53
+ }
54
+
55
+ /**
56
+ * Reactive derived column definitions: name, type, scale (discrete|continuous),
57
+ * sortable, filterable, fields, formatter — merged with any `withColumns` overrides.
58
+ *
59
+ * Re-computes when source data or column enhancements change.
60
+ * Bind to this in templates for Table / TreeTable column specs.
61
+ */
62
+ get columns() {
63
+ return deriveColumnDefs(this.#source, {
64
+ ...this.#columnOptions,
65
+ enhancements: this.#columnEnhancements
66
+ })
67
+ }
68
+
69
+ // ── Configuration (chainable) ────────────────────────────────────────────
70
+
71
+ /**
72
+ * Set the filter predicate. Replaces any previous filter.
73
+ * @param {Function|null} fn
74
+ */
75
+ where(fn) {
76
+ this.#filter = fn
77
+ return this
78
+ }
79
+
80
+ /**
81
+ * Append a row transform. Each call to `apply` adds a new stage.
82
+ * @param {Function} fn
83
+ */
84
+ apply(fn) {
85
+ this.#transforms = [...this.#transforms, { fn }]
86
+ return this
87
+ }
88
+
89
+ /**
90
+ * Set sort fields. Replaces any previous sort.
91
+ * @param {...string} fields
92
+ */
93
+ sortBy(...fields) {
94
+ this.#sortFields = fields
95
+ return this
96
+ }
97
+
98
+ /**
99
+ * Set per-column enhancements / overrides for the reactive `columns` property.
100
+ *
101
+ * Each entry is matched by `name` and merged into the auto-derived column def.
102
+ * Unknown names are appended as synthetic columns (useful for action columns).
103
+ *
104
+ * @param {Array<Object>} enhancements - e.g. [{ name: 'revenue', label: 'Revenue ($)', scale: 'continuous' }]
105
+ * @param {Object} [options] - Options forwarded to deriveColumnDefs (language, scanMode).
106
+ */
107
+ withColumns(enhancements, options = {}) {
108
+ this.#columnEnhancements = enhancements
109
+ this.#columnOptions = options
110
+ return this
111
+ }
112
+
113
+ /** Clear the current filter. */
114
+ clearFilter() {
115
+ this.#filter = null
116
+ return this
117
+ }
118
+
119
+ /** Clear all transforms. */
120
+ clearTransforms() {
121
+ this.#transforms = []
122
+ return this
123
+ }
124
+
125
+ // ── Data mutation ────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Replace the entire source dataset.
129
+ * @param {any[]} rows
130
+ */
131
+ setData(rows) {
132
+ this.#source = rows
133
+ return this
134
+ }
135
+
136
+ /**
137
+ * Append a single row to the source.
138
+ * @param {any} row
139
+ */
140
+ push(row) {
141
+ this.#source = [...this.#source, row]
142
+ return this
143
+ }
144
+
145
+ /**
146
+ * Remove rows matching the predicate from the source.
147
+ * @param {Function} predicate
148
+ */
149
+ remove(predicate) {
150
+ this.#source = this.#source.filter((r) => !predicate(r))
151
+ return this
152
+ }
153
+
154
+ /**
155
+ * Update rows matching the predicate with patch (object or function).
156
+ * @param {Function} predicate
157
+ * @param {Object|Function} patch
158
+ */
159
+ update(predicate, patch) {
160
+ this.#source = this.#source.map((row) => {
161
+ if (!predicate(row)) return row
162
+ return typeof patch === 'function' ? patch(row) : { ...row, ...patch }
163
+ })
164
+ return this
165
+ }
166
+
167
+ /** Return a plain snapshot of the current reactive result (non-reactive copy). */
168
+ snapshot() {
169
+ return [...this.rows]
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Factory function for `ReactiveDataSet`.
175
+ *
176
+ * @param {any[]} [data=[]] - Initial source rows.
177
+ * @returns {ReactiveDataSet}
178
+ */
179
+ export function reactiveDataset(data = []) {
180
+ return new ReactiveDataSet(data)
181
+ }
package/src/filter.js CHANGED
@@ -1,5 +1,22 @@
1
1
  import { filterOperations } from './constants'
2
2
 
3
+ /**
4
+ * Checks if a row matches the given value based on the specified operator.
5
+ * Supports both standard and negated operators by checking the operator string
6
+ * for a leading '!' character, which indicates negation.
7
+ *
8
+ * @param {Object} row - The row object in which to search for the value match.
9
+ * @param {any} value - The value to match against the row's properties.
10
+ * @param {string} operator - The operator used to compare the row's properties to the value.
11
+ * When operator starts with '!', it negates the result of the comparison.
12
+ * @returns {boolean} - Returns true if the row matches the condition; otherwise, returns false.
13
+ */
14
+ function findMatchingRow(row, value, operator) {
15
+ const op = operator.startsWith('!') ? operator.slice(1) : operator
16
+ const matched = Object.keys(row).some((key) => filterOperations[op](row[key], value))
17
+
18
+ return operator.startsWith('!') ? !matched : matched
19
+ }
3
20
  /**
4
21
  * Filters an array of objects based on a specified condition defined in the options.
5
22
  * If a column is provided, it filters based on the value of that column. Otherwise, it filters
@@ -22,24 +39,6 @@ export function filterObjectArray(data, options) {
22
39
  return data.filter((row) => findMatchingRow(row, value, operator))
23
40
  }
24
41
 
25
- /**
26
- * Checks if a row matches the given value based on the specified operator.
27
- * Supports both standard and negated operators by checking the operator string
28
- * for a leading '!' character, which indicates negation.
29
- *
30
- * @param {Object} row - The row object in which to search for the value match.
31
- * @param {any} value - The value to match against the row's properties.
32
- * @param {string} operator - The operator used to compare the row's properties to the value.
33
- * When operator starts with '!', it negates the result of the comparison.
34
- * @returns {boolean} - Returns true if the row matches the condition; otherwise, returns false.
35
- */
36
- function findMatchingRow(row, value, operator) {
37
- const op = operator.startsWith('!') ? operator.slice(1) : operator
38
- const matched = Object.keys(row).some((key) => filterOperations[op](row[key], value))
39
-
40
- return operator.startsWith('!') ? !matched : matched
41
- }
42
-
43
42
  /**
44
43
  * Applies multiple filtering conditions to an array of objects. This function iterates through each filter option,
45
44
  * successively narrowing down the data.
package/src/formatter.js CHANGED
@@ -1,20 +1,4 @@
1
- import { identity } from 'ramda'
2
-
3
- const formatterMap = {
4
- currency: getCurrencyFormatter,
5
- integer: (language) => (val) => val.toLocaleString(language, { maximumFractionDigits: 0 }),
6
- number: (language, decimalPlaces) => (val) =>
7
- val.toLocaleString(language, {
8
- minimumFractionDigits: decimalPlaces,
9
- maximumFractionDigits: decimalPlaces
10
- }),
11
- date: (language) => (val) => val.toLocaleDateString(language),
12
- time: (language) => (val) => val.toLocaleTimeString(language),
13
- object: () => JSON.stringify,
14
- array: () => JSON.stringify, // Combined formatter for 'object' and 'array'
15
- ellipsis: () => () => '...' // Static formatter, no need for parameters
16
- }
17
-
1
+ const identity = (x) => x
18
2
  /**
19
3
  * Returns a currency formatter function that formats a value as a currency string.
20
4
  * @param {string} language - The IETF language tag used for locale-specific formatting.
@@ -39,6 +23,21 @@ function getCurrencyFormatter(language, decimalPlaces) {
39
23
  return formatter
40
24
  }
41
25
 
26
+ const formatterMap = {
27
+ currency: getCurrencyFormatter,
28
+ integer: (language) => (val) => val.toLocaleString(language, { maximumFractionDigits: 0 }),
29
+ number: (language, decimalPlaces) => (val) =>
30
+ val.toLocaleString(language, {
31
+ minimumFractionDigits: decimalPlaces,
32
+ maximumFractionDigits: decimalPlaces
33
+ }),
34
+ date: (language) => (val) => val.toLocaleDateString(language),
35
+ time: (language) => (val) => val.toLocaleTimeString(language),
36
+ object: () => JSON.stringify,
37
+ array: () => JSON.stringify, // Combined formatter for 'object' and 'array'
38
+ ellipsis: () => () => '...' // Static formatter, no need for parameters
39
+ }
40
+
42
41
  /**
43
42
  * Creates a formatter function that formats a value according to the specified type and optional locale settings.
44
43
  * Supported types include 'default' (no formatting), 'integer', 'number', 'date', 'time',, 'object', 'array', 'currency' & 'ellipsis''
package/src/index.js CHANGED
@@ -3,5 +3,17 @@ export * from './types'
3
3
  export { typeOf } from './utils'
4
4
  export { renamer } from './renamer'
5
5
  export { model } from './model'
6
+ export { innerJoin, leftJoin, rightJoin, fullJoin, crossJoin, antiJoin, semiJoin } from './join'
6
7
  export { dataset } from './dataset'
7
8
  export { dataview } from './view'
9
+ export {
10
+ deriveColumns,
11
+ deriveMetadata,
12
+ deriveSortableColumn,
13
+ inferScale,
14
+ addScales,
15
+ deriveColumnDefs,
16
+ mergeColumnEnhancements
17
+ } from './infer'
18
+ export { parseFilters } from './parser'
19
+ export { filterData, filterObjectArray } from './filter'
package/src/infer.js CHANGED
@@ -1,33 +1,35 @@
1
1
  import { ascending, descending } from 'd3-array'
2
2
  import { compact } from '@rokkit/core'
3
- import { omit } from 'ramda'
4
3
  import { createFormatter } from './formatter'
5
4
  import { defaultViewOptions, defaultActionOrder } from './constants'
6
5
  import { typeOf } from './utils'
7
6
  import { deriveColumnProperties } from './metadata'
8
7
 
9
8
  /**
10
- * Derives the sorting information for a column based on the type of the input value.
9
+ * Validates an array to ensure it can be used to create a sorting configuration.
11
10
  *
12
- * @param {import('./types').SortableColumn} value - The value that determines the sorting behavior of the column.
13
- * @returns {import('./types').ColumnSorter} An object containing the 'name' of the column and a 'sorter' function.
14
- * @throws {Error} Throws an error if the value type is not supported or the value format is invalid.
11
+ * @param {Array} value - The array to validate.
12
+ * @throws {Error} Throws an error if the array does not correspond to a [string, boolean] pair.
15
13
  */
16
- export function deriveSortableColumn(value) {
17
- const type = typeOf(value)
18
-
19
- const configGenerators = {
20
- array: arrayConfigGenerator,
21
- object: objectConfigGenerator,
22
- string: stringConfigGenerator
14
+ function arrayValidator(value) {
15
+ if (value.length !== 2 || typeof value[0] !== 'string' || typeof value[1] !== 'boolean') {
16
+ throw new Error('Array should be a pair of [string, boolean]')
23
17
  }
18
+ }
24
19
 
25
- const configGenerator = configGenerators[type]
26
- if (!configGenerator) {
27
- throw new Error(`Invalid value type: ${type}`)
20
+ /**
21
+ * Validates an object to ensure it has the necessary properties for creating a sorting configuration.
22
+ *
23
+ * @param {Object} value - The object to validate.
24
+ * @throws {Error} Throws an error if the object does not have a 'name' property or has an invalid 'sorter'.
25
+ */
26
+ function objectValidator(value) {
27
+ if (typeof value.name !== 'string') {
28
+ throw new Error('The property "name" is required and must be a string')
29
+ }
30
+ if (value.sorter !== undefined && typeof value.sorter !== 'function') {
31
+ throw new Error('Sorter should be a function')
28
32
  }
29
-
30
- return configGenerator(value)
31
33
  }
32
34
 
33
35
  /**
@@ -63,32 +65,28 @@ function stringConfigGenerator(value) {
63
65
  }
64
66
 
65
67
  /**
66
- * Validates an array to ensure it can be used to create a sorting configuration.
68
+ * Derives the sorting information for a column based on the type of the input value.
67
69
  *
68
- * @param {Array} value - The array to validate.
69
- * @throws {Error} Throws an error if the array does not correspond to a [string, boolean] pair.
70
+ * @param {import('./types').SortableColumn} value - The value that determines the sorting behavior of the column.
71
+ * @returns {import('./types').ColumnSorter} An object containing the 'name' of the column and a 'sorter' function.
72
+ * @throws {Error} Throws an error if the value type is not supported or the value format is invalid.
70
73
  */
71
- function arrayValidator(value) {
72
- if (value.length !== 2 || typeof value[0] !== 'string' || typeof value[1] !== 'boolean') {
73
- throw new Error('Array should be a pair of [string, boolean]')
74
- }
75
- }
74
+ export function deriveSortableColumn(value) {
75
+ const type = typeOf(value)
76
76
 
77
- /**
78
- * Validates an object to ensure it has the necessary properties for creating a sorting configuration.
79
- *
80
- * @param {Object} value - The object to validate.
81
- * @throws {Error} Throws an error if the object does not have a 'name' property or has an invalid 'sorter'.
82
- */
83
- function objectValidator(value) {
84
- if (typeof value.name !== 'string') {
85
- throw new Error('The property "name" is required and must be a string')
77
+ const configGenerators = {
78
+ array: arrayConfigGenerator,
79
+ object: objectConfigGenerator,
80
+ string: stringConfigGenerator
86
81
  }
87
- if (value.sorter !== undefined && typeof value.sorter !== 'function') {
88
- throw new Error('Sorter should be a function')
82
+
83
+ const configGenerator = configGenerators[type]
84
+ if (!configGenerator) {
85
+ throw new Error(`Invalid value type: ${type}`)
89
86
  }
90
- }
91
87
 
88
+ return configGenerator(value)
89
+ }
92
90
  /**
93
91
  * Creates a deep scan sample object that contains a union of all keys from all
94
92
  * objects in the array, pulling the values from the last item that contains the key.
@@ -96,11 +94,13 @@ function objectValidator(value) {
96
94
  * @param {Array<Object>} data - An array of objects to deep scan and collect sample values.
97
95
  * @returns {Object} A sample object consolidating all unique keys with their latest non-null values.
98
96
  */
99
- export function getDeepScanSample(data) {
97
+ export function getSample(data, deepScan = true) {
98
+ if (data.length === 0) return {}
99
+ if (!deepScan) return data[0]
100
100
  return data.reduce(
101
101
  (acc, cur) => ({
102
102
  ...acc,
103
- ...compact(omit(Object.keys(acc), cur))
103
+ ...compact(Object.fromEntries(Object.entries(cur).filter(([k]) => !(k in acc))))
104
104
  }),
105
105
  {}
106
106
  )
@@ -117,7 +117,7 @@ export function deriveColumns(data, options) {
117
117
  if (data.length === 0) return []
118
118
 
119
119
  const { scanMode } = { ...defaultViewOptions, ...options }
120
- const sample = scanMode === 'fast' ? data[0] : getDeepScanSample(data)
120
+ const sample = scanMode === 'fast' ? data[0] : getSample(data)
121
121
 
122
122
  return deriveColumnProperties(sample, options)
123
123
  }
@@ -178,31 +178,6 @@ export function inferDataType(values) {
178
178
  return type
179
179
  }
180
180
 
181
- /**
182
- * Derives column metadata from the data to be used in a tabular component.
183
- *
184
- * @param {Array} data - The data to derive column metadata from.
185
- * @returns {Array<import('./types').ColumnMetadata>} - The derived column metadata.
186
- */
187
- export function deriveMetadata(dataArray, options = {}) {
188
- let { columns = [] } = options
189
- if (dataArray.length === 0) return columns
190
- const { actions, language } = { ...defaultViewOptions, ...options }
191
-
192
- if (columns.length === 0) {
193
- columns = deriveColumns(dataArray, options)
194
- } else {
195
- columns.forEach((col) => {
196
- // if (col.sortable && !col.order) col.order = 'none'
197
- if (!col.fields) col.fields = { text: col.name }
198
- })
199
- }
200
- columns = addFormatters(columns, language)
201
- if (actions.length > 0) columns = deriveActions(columns, actions)
202
-
203
- return columns
204
- }
205
-
206
181
  /**
207
182
  * Adds a formatter function to each column in the array based on the column type. If a formatter
208
183
  * function is already present, it will not be overwritten. The formatter is created based on the
@@ -261,3 +236,142 @@ export function deriveActions(columns, input) {
261
236
  }))
262
237
  return [...actionColumns, ...columns]
263
238
  }
239
+
240
+ /**
241
+ * Derives column metadata from the data to be used in a tabular component.
242
+ * @param {Array<import('./types').ColumnMetadata>} columns
243
+ * @param {Array} dataArray - The data to derive column metadata from.
244
+ * @param {Object} [options] - The options to use when deriving column metadata.
245
+ * @returns {Array<import('./types').ColumnMetadata>} - The derived column metadata.
246
+ */
247
+ function deriveColumnMetadata(columns, dataArray, options) {
248
+ if (columns.length === 0) {
249
+ columns = deriveColumns(dataArray, options)
250
+ } else {
251
+ columns.forEach((col) => {
252
+ // if (col.sortable && !col.order) col.order = 'none'
253
+ if (!col.fields) col.fields = { text: col.name }
254
+ })
255
+ }
256
+ return columns
257
+ }
258
+ /**
259
+ * Derives column metadata from the data to be used in a tabular component.
260
+ *
261
+ * @param {Array} data - The data to derive column metadata from.
262
+ * @param {Object} [options] - The options to use when deriving column metadata.
263
+ * @returns {Array<import('./types').ColumnMetadata>} - The derived column metadata.
264
+ */
265
+ export function deriveMetadata(dataArray, options = {}) {
266
+ let { columns = [] } = options
267
+ if (dataArray.length === 0) return columns
268
+ const { actions, language } = { ...defaultViewOptions, ...options }
269
+
270
+ columns = deriveColumnMetadata(columns, dataArray, options)
271
+ columns = addFormatters(columns, language)
272
+ if (actions.length > 0) columns = deriveActions(columns, actions)
273
+
274
+ return columns
275
+ }
276
+
277
+ // ── Scale inference ──────────────────────────────────────────────────────────
278
+
279
+ /**
280
+ * Infers whether a column's values are discrete (categorical) or continuous (quantitative).
281
+ *
282
+ * Rules:
283
+ * - string, boolean → always discrete
284
+ * - date, number (float) → always continuous
285
+ * - integer → discrete when ≤20 unique values OR unique ratio < 5 %,
286
+ * continuous otherwise (e.g. large ID column)
287
+ * - anything else → discrete (safe default for unknown types)
288
+ *
289
+ * @param {any[]} values - All values for the column (may include nulls).
290
+ * @returns {'discrete'|'continuous'}
291
+ */
292
+ function inferIntegerScale(nonNull) {
293
+ const unique = new Set(nonNull).size
294
+ return unique <= 20 || unique / nonNull.length < 0.05 ? 'discrete' : 'continuous'
295
+ }
296
+
297
+ const SCALE_BY_TYPE = {
298
+ string: () => 'discrete',
299
+ boolean: () => 'discrete',
300
+ date: () => 'continuous',
301
+ number: () => 'continuous',
302
+ integer: (nonNull) => inferIntegerScale(nonNull)
303
+ }
304
+
305
+ export function inferScale(values) {
306
+ const nonNull = values.filter((v) => v !== null && v !== undefined)
307
+ if (nonNull.length === 0) return 'discrete'
308
+ const type = typeOf(nonNull[0])
309
+ return (SCALE_BY_TYPE[type] ?? (() => 'discrete'))(nonNull)
310
+ }
311
+
312
+ /**
313
+ * Adds a `scale` property to each column definition.
314
+ *
315
+ * @param {Array<Object>} columns - Column definitions (from deriveColumns / deriveMetadata).
316
+ * @param {any[]} data - Source rows.
317
+ * @returns {Array<Object>} Column definitions with `scale` added.
318
+ */
319
+ export function addScales(columns, data) {
320
+ return columns.map((col) => ({
321
+ ...col,
322
+ scale: inferScale(data.map((r) => r[col.name]))
323
+ }))
324
+ }
325
+
326
+ /**
327
+ * Merges user-provided enhancements into auto-derived column definitions.
328
+ * Enhancements are matched by `name` and merged (user values win).
329
+ * Enhancements for unknown column names are appended as synthetic columns.
330
+ *
331
+ * @param {Array<Object>} derived - Auto-derived column definitions.
332
+ * @param {Array<Object>} enhancements - User-provided overrides/additions.
333
+ * @returns {Array<Object>} Merged column definitions.
334
+ */
335
+ export function mergeColumnEnhancements(derived, enhancements) {
336
+ const enhMap = new Map(enhancements.map((e) => [e.name, e]))
337
+ const result = derived.map((col) => {
338
+ const enh = enhMap.get(col.name)
339
+ return enh ? { ...col, ...enh } : col
340
+ })
341
+ const derivedNames = new Set(derived.map((c) => c.name))
342
+ for (const e of enhancements) {
343
+ if (!derivedNames.has(e.name)) result.push(e)
344
+ }
345
+ return result
346
+ }
347
+
348
+ /**
349
+ * Full column definition pipeline: derive → format → scale → merge enhancements.
350
+ *
351
+ * Produces column descriptors suitable for use in Table, TreeTable, and chart
352
+ * components. Each column gets: name, type, scale, sortable, filterable, fields,
353
+ * formatter — plus any user-supplied properties from `enhancements`.
354
+ *
355
+ * @param {any[]} data - Source rows.
356
+ * @param {Object} [options] - Options forwarded to deriveColumns / addFormatters.
357
+ * @param {Array<Object>} [options.enhancements=[]] - Per-column overrides keyed by name.
358
+ * Use to override scale, add a label, attach a custom formatter, etc.
359
+ * @param {string} [options.language='en-US'] - Locale for formatters.
360
+ * @param {string} [options.scanMode='fast'] - 'fast' (first row) or 'deep' (all rows).
361
+ * @returns {Array<Object>} Column definitions with scale and formatters.
362
+ */
363
+ export function deriveColumnDefs(data, options = {}) {
364
+ const { enhancements = [], language, scanMode } = { ...defaultViewOptions, ...options }
365
+
366
+ if (data.length === 0) return enhancements
367
+
368
+ let columns = deriveColumns(data, { scanMode })
369
+ columns = addFormatters(columns, language)
370
+ columns = addScales(columns, data)
371
+
372
+ if (enhancements.length > 0) {
373
+ columns = mergeColumnEnhancements(columns, enhancements)
374
+ }
375
+
376
+ return columns
377
+ }