@slickgrid-universal/sql 0.0.1 → 10.4.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 (33) hide show
  1. package/README.md +11 -39
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/interfaces/index.d.ts +4 -0
  7. package/dist/interfaces/index.d.ts.map +1 -0
  8. package/dist/interfaces/index.js +4 -0
  9. package/dist/interfaces/index.js.map +1 -0
  10. package/dist/interfaces/sqlResult.interface.d.ts +6 -0
  11. package/dist/interfaces/sqlResult.interface.d.ts.map +1 -0
  12. package/dist/interfaces/sqlResult.interface.js +2 -0
  13. package/dist/interfaces/sqlResult.interface.js.map +1 -0
  14. package/dist/interfaces/sqlServiceApi.interface.d.ts +22 -0
  15. package/dist/interfaces/sqlServiceApi.interface.d.ts.map +1 -0
  16. package/dist/interfaces/sqlServiceApi.interface.js +2 -0
  17. package/dist/interfaces/sqlServiceApi.interface.js.map +1 -0
  18. package/dist/interfaces/sqlServiceOption.interface.d.ts +54 -0
  19. package/dist/interfaces/sqlServiceOption.interface.d.ts.map +1 -0
  20. package/dist/interfaces/sqlServiceOption.interface.js +2 -0
  21. package/dist/interfaces/sqlServiceOption.interface.js.map +1 -0
  22. package/dist/services/sql.service.d.ts +69 -0
  23. package/dist/services/sql.service.d.ts.map +1 -0
  24. package/dist/services/sql.service.js +600 -0
  25. package/dist/services/sql.service.js.map +1 -0
  26. package/package.json +44 -7
  27. package/src/index.ts +2 -0
  28. package/src/interfaces/index.ts +3 -0
  29. package/src/interfaces/sqlResult.interface.ts +6 -0
  30. package/src/interfaces/sqlServiceApi.interface.ts +27 -0
  31. package/src/interfaces/sqlServiceOption.interface.ts +61 -0
  32. package/src/services/__tests__/sql.service.spec.ts +1819 -0
  33. package/src/services/sql.service.ts +691 -0
@@ -0,0 +1,691 @@
1
+ import type {
2
+ BackendService,
3
+ Column,
4
+ ColumnFilters,
5
+ CurrentFilter,
6
+ CurrentPagination,
7
+ CurrentSorter,
8
+ FilterChangedArgs,
9
+ GridOption,
10
+ MultiColumnSort,
11
+ OperatorType,
12
+ Pagination,
13
+ PaginationChangedArgs,
14
+ PaginationCursorChangedArgs,
15
+ SingleColumnSort,
16
+ SlickGrid,
17
+ SortDirection,
18
+ } from '@slickgrid-universal/common';
19
+ import { getHtmlStringOutput, stripTags } from '@slickgrid-universal/utils';
20
+ import type { SqlResult } from '../interfaces/sqlResult.interface.js';
21
+ import type { SqlFilteringOption, SqlServiceOption, SqlSortingOption } from '../interfaces/sqlServiceOption.interface.js';
22
+
23
+ const DEFAULT_TOTAL_COUNT_FIELD = 'totalCount';
24
+ const DEFAULT_ITEMS_PER_PAGE = 25;
25
+ const DEFAULT_PAGE_SIZE = 20;
26
+
27
+ /**
28
+ * SqlService implements BackendService for SQL query generation.
29
+ * This is a basic implementation; extend as needed for your SQL dialect.
30
+ */
31
+ export class SqlService implements BackendService {
32
+ protected _currentFilters: ColumnFilters | CurrentFilter[] = [];
33
+ protected _currentPagination: CurrentPagination | null = null;
34
+ protected _currentSorters: CurrentSorter[] = [];
35
+ protected _columns?: any[];
36
+ protected _grid: SlickGrid | undefined;
37
+ options?: SqlServiceOption;
38
+ pagination?: Pagination;
39
+
40
+ /** Getter for the Grid Options pulled through the Grid Object */
41
+ protected get _gridOptions(): GridOption {
42
+ return this._grid?.getOptions() ?? ({} as GridOption);
43
+ }
44
+
45
+ init(serviceOptions?: SqlServiceOption, pagination?: Pagination, grid?: SlickGrid): void {
46
+ this.options = serviceOptions || { tableName: '' };
47
+ this._grid = grid;
48
+ if (typeof grid?.getColumns === 'function') {
49
+ this._columns = grid.getColumns() ?? [];
50
+ }
51
+ if (pagination) {
52
+ this._currentPagination = {
53
+ pageNumber: pagination.pageNumber ?? 1,
54
+ pageSize: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
55
+ };
56
+ // Save the full pagination object for totalItems
57
+ this.pagination = pagination;
58
+ } else {
59
+ this._currentPagination = {
60
+ pageNumber: 1,
61
+ pageSize: DEFAULT_PAGE_SIZE,
62
+ };
63
+ this.pagination = undefined;
64
+ }
65
+ }
66
+
67
+ buildQuery(): string {
68
+ if (!this.options || !this.options.tableName || !Array.isArray(this._columns)) {
69
+ throw new Error('SQL Service requires the "tableName" property and columns to properly build the SQL query');
70
+ }
71
+ // Use datasetName as schema/database prefix if provided, and escape identifiers per DB style
72
+ const table = this.options.datasetName
73
+ ? `${this.escapeIdentifier(this.options.datasetName)}.${this.escapeIdentifier(this.options.tableName)}`
74
+ : this.escapeIdentifier(this.options.tableName);
75
+
76
+ // Build the list of fields for SELECT, escaping identifiers
77
+ let selectFields: string[] = [];
78
+ for (const col of this._columns) {
79
+ // Only flat fields (no dot notation)
80
+ if (typeof col.field === 'string' && !col.field.includes('.')) {
81
+ if (!col.excludeFromQuery && !col.excludeFieldFromQuery) {
82
+ selectFields.push(this.escapeIdentifier(col.field));
83
+ }
84
+ }
85
+ // Add extra fields from the 'fields' property if present (and flat)
86
+ if (Array.isArray(col.fields)) {
87
+ for (const extraField of col.fields) {
88
+ if (typeof extraField === 'string' && !extraField.includes('.')) {
89
+ selectFields.push(this.escapeIdentifier(extraField));
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ // Remove duplicates
96
+ selectFields = Array.from(new Set(selectFields));
97
+
98
+ // If all flat fields are included and no extra fields, use SELECT *
99
+ const allFlatCols = this._columns.filter((col) => typeof col.field === 'string' && !col.field.includes('.'));
100
+ const allIncluded =
101
+ selectFields.length === allFlatCols.length && allFlatCols.every((col) => selectFields.includes(this.escapeIdentifier(col.field)));
102
+ let selectCols = allIncluded && selectFields.length > 0 ? '*' : selectFields.join(', ');
103
+ if (!selectCols || selectCols.trim() === '') {
104
+ selectCols = '*';
105
+ }
106
+
107
+ // Pagination logic
108
+ let pageSize = this._currentPagination?.pageSize || DEFAULT_PAGE_SIZE;
109
+ let pageNumber = this._currentPagination?.pageNumber;
110
+ if (!pageNumber && pageNumber !== 0) {
111
+ pageNumber = 1;
112
+ }
113
+
114
+ // Infinite scroll: use fetchSize if provided, else pageSize
115
+ const infiniteScroll = this.options?.infiniteScroll;
116
+ let effectivePageSize = pageSize;
117
+ if (infiniteScroll && typeof infiniteScroll === 'object' && typeof infiniteScroll.fetchSize === 'number') {
118
+ effectivePageSize = infiniteScroll.fetchSize;
119
+ }
120
+
121
+ let limit = '';
122
+ let offset = '';
123
+ if (this._gridOptions.enablePagination) {
124
+ limit = effectivePageSize ? `LIMIT ${effectivePageSize}` : '';
125
+ // Offset should never be below zero
126
+ const calcOffset = Math.max(0, ((pageNumber ?? 1) - 1) * (effectivePageSize ?? DEFAULT_ITEMS_PER_PAGE));
127
+ offset = effectivePageSize ? `OFFSET ${calcOffset}` : '';
128
+ }
129
+
130
+ // Allow user to customize the total count field name
131
+ const totalCountField = this.options.totalCountField || DEFAULT_TOTAL_COUNT_FIELD;
132
+ // Add the total count window function (escape count field)
133
+ const selectWithCount = `${selectCols === '*' ? '*' : selectCols}, COUNT(*) OVER() AS ${this.escapeIdentifier(totalCountField)}`;
134
+
135
+ const where = this.buildWhereClause();
136
+ const order = this.buildOrderByClause();
137
+ return `SELECT ${selectWithCount} FROM ${table}${where}${order} ${limit} ${offset}`.trim();
138
+ }
139
+
140
+ /**
141
+ * Post-process the SQL result to extract total count for pagination.
142
+ * Uses the configured totalCountField option (default: 'totalCount').
143
+ */
144
+ postProcess<T = any>(processResult: SqlResult<T> | T[] | { data?: T[]; [key: string]: any }): void {
145
+ if (this.pagination) {
146
+ const totalCountField = this.options?.totalCountField || DEFAULT_TOTAL_COUNT_FIELD;
147
+ // SQL: result is SqlResult<T>
148
+ if (processResult && Array.isArray((processResult as SqlResult<T>).data) && (processResult as SqlResult<T>).data.length > 0) {
149
+ const firstRow = (processResult as SqlResult<T>).data[0];
150
+ if (firstRow && typeof (firstRow as any)[totalCountField] === 'number') {
151
+ this.pagination.totalItems = (firstRow as any)[totalCountField];
152
+ return;
153
+ }
154
+ }
155
+ // SQL: result is an array of rows
156
+ if (Array.isArray(processResult) && processResult.length > 0 && typeof (processResult[0] as any)[totalCountField] === 'number') {
157
+ this.pagination.totalItems = (processResult[0] as any)[totalCountField];
158
+ return;
159
+ }
160
+ // Fallback: flat totalCount
161
+ if (typeof (processResult as any)[totalCountField] === 'number') {
162
+ this.pagination.totalItems = (processResult as any)[totalCountField];
163
+ }
164
+ }
165
+ }
166
+
167
+ clearFilters(): void {
168
+ this._currentFilters = [];
169
+ this.updateOptions({ filteringOptions: [] });
170
+ }
171
+
172
+ clearSorters(): void {
173
+ this._currentSorters = [];
174
+ this.updateOptions({ sortingOptions: [] });
175
+ }
176
+
177
+ /** Get the dataset name */
178
+ getDatasetName(): string {
179
+ return this.options?.datasetName || '';
180
+ }
181
+
182
+ /** Get the table name */
183
+ getTableName(): string {
184
+ return this.options?.tableName || '';
185
+ }
186
+
187
+ /** Get the Filters that are currently used by the grid */
188
+ getCurrentFilters(): ColumnFilters | CurrentFilter[] {
189
+ return this._currentFilters;
190
+ }
191
+
192
+ /** Get the Pagination that is currently used by the grid */
193
+ getCurrentPagination(): CurrentPagination | null {
194
+ return this._currentPagination;
195
+ }
196
+
197
+ /** Get the Sorters that are currently used by the grid */
198
+ getCurrentSorters(): CurrentSorter[] {
199
+ return this._currentSorters;
200
+ }
201
+
202
+ /**
203
+ * Returns the initial pagination options for SQL queries.
204
+ * Returns { pageSize, offset } based on current or default pagination.
205
+ */
206
+ getInitPaginationOptions(): { pageSize: number; offset: number } {
207
+ let pageSize = this._currentPagination?.pageSize ?? DEFAULT_PAGE_SIZE;
208
+ let pageNumber = this._currentPagination?.pageNumber ?? 1;
209
+ const offset = Math.max(0, (pageNumber - 1) * pageSize);
210
+ return { pageSize, offset };
211
+ }
212
+
213
+ /* Reset the pagination options */
214
+ resetPaginationOptions(): void {
215
+ if (this._currentPagination) {
216
+ this._currentPagination.pageNumber = 1;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Update column filters by looping through all columns to inspect filters & update backend service filteringOptions
222
+ * @param columnFilters
223
+ */
224
+ updateFilters(columnFilters: ColumnFilters | CurrentFilter[], isUpdatedByPresetOrDynamically: boolean): void {
225
+ const filteringOptions: SqlFilteringOption[] = [];
226
+ if (isUpdatedByPresetOrDynamically) {
227
+ this._currentFilters = this.castFilterToColumnFilters(columnFilters);
228
+ }
229
+
230
+ for (const columnId in columnFilters) {
231
+ if (columnId in columnFilters) {
232
+ const columnFilter = (columnFilters as any)[columnId];
233
+
234
+ let columnDef: Column | undefined;
235
+ if (isUpdatedByPresetOrDynamically && Array.isArray(this._columns)) {
236
+ columnDef = this._columns.find((col: Column) => col.id === columnFilter.columnId);
237
+ } else {
238
+ columnDef = columnFilter.columnDef;
239
+ }
240
+ if (!columnDef) {
241
+ throw new Error('[SQL Service]: Something went wrong in trying to get the column definition');
242
+ }
243
+
244
+ let fieldName =
245
+ columnDef.filter?.queryField || columnDef.queryFieldFilter || columnDef.queryField || columnDef.field || columnDef.name || '';
246
+ if (fieldName instanceof HTMLElement) {
247
+ fieldName = stripTags(fieldName.innerHTML);
248
+ }
249
+ const fieldType = columnDef.type || 'string';
250
+ let searchTerms = (columnFilter?.searchTerms ? [...columnFilter.searchTerms] : null) || [];
251
+ let fieldSearchValue = Array.isArray(searchTerms) && searchTerms.length === 1 ? searchTerms[0] : '';
252
+ if (typeof fieldSearchValue === 'undefined') {
253
+ fieldSearchValue = '';
254
+ }
255
+
256
+ if (!fieldName) {
257
+ throw new Error(
258
+ 'SQL filter could not find the field name to query the search, your column definition must include a valid "field" or "name" (optionally you can also use the "queryfield").'
259
+ );
260
+ }
261
+
262
+ if (this.options?.useVerbatimSearchTerms || columnFilter.verbatimSearchTerms) {
263
+ let vbOperator = columnFilter.operator || '';
264
+ let vbValue = columnFilter.searchTerms;
265
+
266
+ if (Array.isArray(columnFilter.searchTerms)) {
267
+ if (columnFilter.searchTerms.length === 1) {
268
+ vbOperator = '=';
269
+ vbValue = columnFilter.searchTerms[0];
270
+ } else {
271
+ vbOperator = 'IN';
272
+ vbValue = columnFilter.searchTerms;
273
+ }
274
+ }
275
+
276
+ filteringOptions.push({
277
+ field: getHtmlStringOutput(fieldName),
278
+ operator: vbOperator,
279
+ value: vbValue,
280
+ type: fieldType,
281
+ });
282
+ continue;
283
+ }
284
+
285
+ fieldSearchValue = fieldSearchValue === undefined || fieldSearchValue === null ? '' : `${fieldSearchValue}`;
286
+
287
+ // run regex to find possible filter operators unless the user disabled the feature
288
+ const autoParseInputFilterOperator = columnDef.autoParseInputFilterOperator ?? this._gridOptions.autoParseInputFilterOperator;
289
+
290
+ // group (2): comboStartsWith, (3): comboEndsWith, (4): Operator, (1 or 5): searchValue, (6): last char is '*' (meaning starts with, ex.: abc*)
291
+ const matches =
292
+ autoParseInputFilterOperator !== false
293
+ ? fieldSearchValue.match(/^((.*[^\\*\r\n])[*]{1}(.*[^*\r\n]))|^([<>!=*]{0,2})(.*[^<>!=*])([*]?)$/) || []
294
+ : [fieldSearchValue, '', '', '', '', fieldSearchValue, ''];
295
+
296
+ const comboStartsWith = matches?.[2] || '';
297
+ const comboEndsWith = matches?.[3] || '';
298
+ let operator = columnFilter.operator || matches?.[4];
299
+ let searchVal = matches?.[1] || matches?.[5] || '';
300
+ const lastValueChar = matches?.[6] || operator === '*z' || operator === 'EndsWith' ? '*' : '';
301
+
302
+ // no need to query if search value is empty
303
+ if (fieldName && searchVal === '' && searchTerms.length === 0) {
304
+ continue;
305
+ }
306
+
307
+ // StartsWith + EndsWith combo
308
+ if (comboStartsWith && comboEndsWith) {
309
+ searchTerms = [comboStartsWith, comboEndsWith];
310
+ operator = 'StartsWithEndsWith';
311
+ } else if (
312
+ Array.isArray(searchTerms) &&
313
+ searchTerms.length === 1 &&
314
+ typeof searchTerms[0] === 'string' &&
315
+ searchTerms[0].indexOf('..') >= 0
316
+ ) {
317
+ if (operator !== 'RangeInclusive' && operator !== 'RangeExclusive') {
318
+ operator = this._gridOptions.defaultFilterRangeOperator ?? 'RangeInclusive';
319
+ }
320
+ searchTerms = searchTerms[0].split('..', 2);
321
+ if (searchTerms[0] === '') {
322
+ operator = operator === 'RangeInclusive' ? '<=' : operator === 'RangeExclusive' ? '<' : operator;
323
+ searchTerms = searchTerms.slice(1);
324
+ searchVal = searchTerms[0];
325
+ } else if (searchTerms[1] === '') {
326
+ operator = operator === 'RangeInclusive' ? '>=' : operator === 'RangeExclusive' ? '>' : operator;
327
+ searchTerms = searchTerms.slice(0, 1);
328
+ searchVal = searchTerms[0];
329
+ }
330
+ }
331
+
332
+ if (typeof searchVal === 'string') {
333
+ if (operator === '*' || operator === 'a*' || operator === '*z' || lastValueChar === '*') {
334
+ operator = (operator === '*' || operator === '*z' ? 'EndsWith' : 'StartsWith') as OperatorType;
335
+ }
336
+ }
337
+
338
+ // if we didn't find an Operator but we have a Column Operator inside the Filter (DOM Element), we should use its default Operator
339
+ if (!operator && columnDef.filter?.operator) {
340
+ operator = columnDef.filter.operator;
341
+ }
342
+
343
+ // No operator and 2 search terms should lead to default range operator.
344
+ if (!operator && Array.isArray(searchTerms) && searchTerms.length === 2 && searchTerms[0] && searchTerms[1]) {
345
+ operator = this._gridOptions.defaultFilterRangeOperator as OperatorType;
346
+ }
347
+
348
+ // Range with 1 searchterm should lead to equals for a date field.
349
+ if (
350
+ (operator === 'RangeInclusive' || operator === 'RangeExclusive') &&
351
+ Array.isArray(searchTerms) &&
352
+ searchTerms.length === 1 &&
353
+ fieldType === 'date'
354
+ ) {
355
+ operator = 'EQ';
356
+ }
357
+
358
+ // if we still don't have an operator find the proper Operator to use according to field type
359
+ if (!operator) {
360
+ operator = fieldType === 'number' || fieldType === 'integer' || fieldType === 'float' ? '=' : 'LIKE';
361
+ }
362
+
363
+ // Normalize all search values
364
+ searchVal = this.normalizeSearchValue(fieldType, searchVal);
365
+ if (Array.isArray(searchTerms)) {
366
+ searchTerms.forEach((_part, index) => {
367
+ searchTerms[index] = this.normalizeSearchValue(fieldType, searchTerms[index]);
368
+ });
369
+ }
370
+
371
+ // StartsWith + EndsWith combo
372
+ if (operator === 'StartsWithEndsWith' && Array.isArray(searchTerms) && searchTerms.length === 2) {
373
+ filteringOptions.push({ field: getHtmlStringOutput(fieldName), operator: 'LIKE', value: `${searchTerms[0]}%`, type: fieldType });
374
+ filteringOptions.push({ field: getHtmlStringOutput(fieldName), operator: 'LIKE', value: `%${searchTerms[1]}`, type: fieldType });
375
+ continue;
376
+ }
377
+ // IN/NOT IN
378
+ if (searchTerms?.length > 1 && (operator === 'IN' || operator === 'NIN' || operator === 'NOT_IN')) {
379
+ filteringOptions.push({
380
+ field: getHtmlStringOutput(fieldName),
381
+ operator: operator === 'IN' ? 'IN' : 'NOT IN',
382
+ value: searchTerms,
383
+ type: fieldType,
384
+ });
385
+ continue;
386
+ } else if (searchTerms?.length === 2 && (operator === 'RangeExclusive' || operator === 'RangeInclusive')) {
387
+ filteringOptions.push({
388
+ field: getHtmlStringOutput(fieldName),
389
+ operator: operator === 'RangeInclusive' ? '>=' : '>',
390
+ value: searchTerms[0],
391
+ type: fieldType,
392
+ });
393
+ filteringOptions.push({
394
+ field: getHtmlStringOutput(fieldName),
395
+ operator: operator === 'RangeInclusive' ? '<=' : '<',
396
+ value: searchTerms[1],
397
+ type: fieldType,
398
+ });
399
+ continue;
400
+ }
401
+
402
+ // Always map these string operators
403
+ if (fieldType === 'string' || fieldType === 'text' || fieldType === 'readonly') {
404
+ if (operator === '<>' || operator === 'Not_Contains' || operator === 'NOT_CONTAINS') {
405
+ // prettier-ignore
406
+ filteringOptions.push({ field: getHtmlStringOutput(fieldName), operator: 'NOT LIKE', value: `%${searchVal}%`, type: fieldType });
407
+ continue;
408
+ }
409
+ if (operator === 'Contains' || operator === 'CONTAINS') {
410
+ filteringOptions.push({ field: getHtmlStringOutput(fieldName), operator: 'LIKE', value: `%${searchVal}%`, type: fieldType });
411
+ continue;
412
+ }
413
+ if (operator === '*' || operator === '*z' || operator === 'EndsWith') {
414
+ filteringOptions.push({ field: getHtmlStringOutput(fieldName), operator: 'LIKE', value: `%${searchVal}`, type: fieldType });
415
+ continue;
416
+ }
417
+ if (operator === 'StartsWith' || operator === 'a*' || lastValueChar === '*') {
418
+ filteringOptions.push({ field: getHtmlStringOutput(fieldName), operator: 'LIKE', value: `${searchVal}%`, type: fieldType });
419
+ continue;
420
+ }
421
+ }
422
+
423
+ // Fallback: use field/operator/value
424
+ filteringOptions.push({ field: getHtmlStringOutput(fieldName), operator: operator ?? '=', value: searchVal, type: fieldType });
425
+ }
426
+ }
427
+
428
+ this.updateOptions({ filteringOptions });
429
+ }
430
+
431
+ updatePagination(newPage: number, pageSize: number, _cursorArgs?: PaginationCursorChangedArgs): void {
432
+ const finalPageSize = pageSize || DEFAULT_PAGE_SIZE;
433
+ this._currentPagination = { pageNumber: newPage, pageSize: finalPageSize };
434
+ this.updateOptions({ paginationOptions: { pageNumber: newPage, pageSize: finalPageSize } });
435
+ }
436
+
437
+ updateSorters(sortColumns?: Array<SingleColumnSort>, presetSorters?: CurrentSorter[]): void {
438
+ let currentSorters: CurrentSorter[] = [];
439
+ const sqlSorters: SqlSortingOption[] = [];
440
+
441
+ if (!sortColumns && presetSorters) {
442
+ // make the presets the current sorters, also make sure that all direction are in uppercase
443
+ currentSorters = presetSorters.map((sorter) => ({
444
+ columnId: sorter.columnId,
445
+ direction: sorter.direction.toUpperCase() as SortDirection,
446
+ }));
447
+
448
+ // display the correct sorting icons on the UI, for that it requires (columnId, sortAsc) properties
449
+ const tmpSorterArray = currentSorters.map((sorter) => {
450
+ const columnDef = this._columns?.find((column: Column) => column.id === sorter.columnId);
451
+ sqlSorters.push({
452
+ field: columnDef ? (columnDef.queryFieldSorter || columnDef.queryField || columnDef.field) + '' : sorter.columnId + '',
453
+ direction: sorter.direction,
454
+ });
455
+
456
+ // return only the column(s) found in the Column Definitions ELSE null
457
+ if (columnDef) {
458
+ return {
459
+ columnId: sorter.columnId,
460
+ sortAsc: sorter.direction.toUpperCase() === 'ASC',
461
+ };
462
+ }
463
+ return null;
464
+ }) as { columnId: string | number; sortAsc: boolean }[] | null;
465
+
466
+ // set the sort icons, but also make sure to filter out null values (that happens when columnDef is not found)
467
+ if (Array.isArray(tmpSorterArray) && this._grid) {
468
+ this._grid.setSortColumns(tmpSorterArray.filter((sorter) => sorter) || []);
469
+ }
470
+ } else if (sortColumns && !presetSorters) {
471
+ // build the orderBy array, it could be multisort, example
472
+ // orderBy:[{field: lastName, direction: ASC}, {field: firstName, direction: DESC}]
473
+ if (Array.isArray(sortColumns) && sortColumns.length > 0) {
474
+ for (const sortColumn of sortColumns) {
475
+ if (sortColumn && sortColumn.sortCol) {
476
+ currentSorters.push({
477
+ columnId: String(sortColumn.sortCol.id),
478
+ direction: sortColumn.sortAsc ? 'ASC' : 'DESC',
479
+ });
480
+
481
+ const fieldName = (sortColumn.sortCol.queryFieldSorter || sortColumn.sortCol.queryField || sortColumn.sortCol.field || '') + '';
482
+ if (fieldName) {
483
+ sqlSorters.push({
484
+ field: fieldName,
485
+ direction: sortColumn.sortAsc ? 'ASC' : 'DESC',
486
+ });
487
+ }
488
+ }
489
+ }
490
+ }
491
+ }
492
+
493
+ // keep current Sorters and update the service options with the new sorting
494
+ this._currentSorters = currentSorters;
495
+ this.updateOptions({ sortingOptions: sqlSorters });
496
+ }
497
+
498
+ updateOptions(serviceOptions?: Partial<SqlServiceOption>): void {
499
+ this.options = {
500
+ ...this.options,
501
+ ...serviceOptions,
502
+ datasetName: this.options?.datasetName || '',
503
+ tableName: this.options?.tableName || '',
504
+ };
505
+ }
506
+
507
+ processOnFilterChanged(_event: Event | KeyboardEvent | undefined, args: FilterChangedArgs): string {
508
+ if (!args || !args.grid) {
509
+ throw new Error('SQLService: "args" is not populated correctly');
510
+ }
511
+ // keep current filters & always save it as an array (columnFilters can be an object when it is dealt by SlickGrid Filter)
512
+ this._currentFilters = this.castFilterToColumnFilters(args.columnFilters);
513
+
514
+ // loop through all columns to inspect filters & set the query
515
+ this.updateFilters(args.columnFilters, false);
516
+ this.resetPaginationOptions();
517
+ return this.buildQuery();
518
+ }
519
+
520
+ processOnPaginationChanged(
521
+ _event: Event | undefined,
522
+ args: PaginationChangedArgs | (PaginationCursorChangedArgs & PaginationChangedArgs)
523
+ ): string {
524
+ // Use current pageSize if not provided in args
525
+ const pageSize = args.pageSize ?? this._currentPagination?.pageSize ?? DEFAULT_PAGE_SIZE;
526
+ this.updatePagination(args.newPage, pageSize);
527
+ return this.buildQuery();
528
+ }
529
+
530
+ processOnSortChanged(_event: Event | undefined, args: SingleColumnSort | MultiColumnSort): string {
531
+ if ('sortCols' in args) {
532
+ // MultiColumnSort: pass the array of SingleColumnSort
533
+ this.updateSorters(args.sortCols as SingleColumnSort[]);
534
+ } else {
535
+ // SingleColumnSort: wrap in array
536
+ this.updateSorters([args as SingleColumnSort]);
537
+ }
538
+ return this.buildQuery();
539
+ }
540
+
541
+ // --
542
+ // PROTECTED METHODS
543
+ // --
544
+
545
+ protected buildWhereClause(): string {
546
+ // Build WHERE clause from filteringOptions
547
+ const filteringOptions = this.options?.filteringOptions || [];
548
+ if (!Array.isArray(filteringOptions) || filteringOptions.length === 0) {
549
+ return '';
550
+ }
551
+
552
+ const clauses: string[] = [];
553
+ for (const filter of filteringOptions) {
554
+ const { field, operator, value, type } = filter;
555
+ if (field && operator) {
556
+ let sqlOperator = operator === 'EQ' ? '=' : operator;
557
+ if (sqlOperator === 'NOT_CONTAINS') {
558
+ sqlOperator = 'NOT LIKE';
559
+ } else if (sqlOperator === 'Contains') {
560
+ sqlOperator = 'LIKE';
561
+ }
562
+ const fieldExpr = this.escapeIdentifier(field);
563
+ if (sqlOperator === '=' && value === null) {
564
+ clauses.push(`${fieldExpr} IS NULL`);
565
+ } else if (sqlOperator === 'IN' || sqlOperator === 'NOT IN') {
566
+ if (Array.isArray(value)) {
567
+ if (value.length === 0) {
568
+ continue;
569
+ }
570
+ const inList = value.map((v) => this._escapeSql(v, type)).join(',');
571
+ clauses.push(`${fieldExpr} ${sqlOperator} (${inList})`);
572
+ } else {
573
+ clauses.push(`${fieldExpr} ${sqlOperator} (${this._escapeSql(value, type)})`);
574
+ }
575
+ } else if (sqlOperator === 'LIKE' || sqlOperator === 'NOT LIKE') {
576
+ let likeValue = value;
577
+ if (typeof likeValue === 'string' && !likeValue.includes('%')) {
578
+ likeValue = `%${likeValue}%`;
579
+ }
580
+ clauses.push(`${fieldExpr} ${sqlOperator} ${this._escapeSql(likeValue, type)}`);
581
+ } else {
582
+ clauses.push(`${fieldExpr} ${sqlOperator} ${this._escapeSql(value, type)}`);
583
+ }
584
+ }
585
+ }
586
+ return clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
587
+ }
588
+
589
+ protected buildOrderByClause(): string {
590
+ if (!this.options || !this.options.tableName || !Array.isArray(this._columns)) {
591
+ throw new Error('SQL Service requires the "tableName" property to properly build the SQL query');
592
+ }
593
+
594
+ const order = this.options.sortingOptions
595
+ ?.map((s) => {
596
+ // Find the column definition by id
597
+ const colDef = this._columns?.find((col) => col.id === s.field);
598
+ let sortField = colDef
599
+ ? String(colDef.queryFieldSorter || colDef.queryField || colDef.field || colDef.id || s.field)
600
+ : String(s.field);
601
+
602
+ // Only include flat fields (no dot notation)
603
+ if (!sortField.includes('.')) {
604
+ return `${this.escapeIdentifier(sortField)} ${s.direction}`;
605
+ }
606
+ return '';
607
+ })
608
+ .join(', ');
609
+ return order ? ` ORDER BY ${order}` : '';
610
+ }
611
+
612
+ protected castFilterToColumnFilters(columnFilters: ColumnFilters | CurrentFilter[]): CurrentFilter[] {
613
+ if (Array.isArray(columnFilters)) {
614
+ // Ensure all columnId are strings for CurrentFilter
615
+ return columnFilters.map((f) => ({
616
+ ...f,
617
+ columnId: String(f.columnId),
618
+ }));
619
+ }
620
+
621
+ // For object form, ensure columnId is string
622
+ return Object.keys(columnFilters).map((key) => {
623
+ const filter = columnFilters[key];
624
+ return {
625
+ ...filter,
626
+ columnId: String(filter.columnId),
627
+ };
628
+ });
629
+ }
630
+
631
+ protected _escapeSql(val: any, type?: string): string {
632
+ if (type === 'number' || type === 'integer' || type === 'float' || typeof val === 'number') {
633
+ return val.toString();
634
+ }
635
+ if (val === null || val === undefined) {
636
+ return 'NULL';
637
+ }
638
+ if (val === '') {
639
+ return "''";
640
+ }
641
+
642
+ // Escape single quotes for SQL
643
+ return `'${String(val).replace(/'/g, "''")}'`;
644
+ }
645
+
646
+ /** Escapes SQL identifiers (table, column, etc.) based on the configured escape style. */
647
+ protected escapeIdentifier(identifier?: string): string {
648
+ const escapeStyle = this.options?.identifierEscapeStyle || 'doubleQuote';
649
+ if (!identifier) return '';
650
+ switch (escapeStyle) {
651
+ case 'backtick':
652
+ return `\`${String(identifier).replace(/`/g, '``')}\``;
653
+ case 'bracket':
654
+ return `[${String(identifier).replace(/]/g, ']]')}]`;
655
+ case 'doubleQuote':
656
+ default:
657
+ return `"${String(identifier).replace(/"/g, '""')}"`;
658
+ }
659
+ }
660
+
661
+ /** Normalizes the search value according to field type. */
662
+ protected normalizeSearchValue(fieldType: string, searchValue: any): any {
663
+ switch (fieldType) {
664
+ case 'date':
665
+ case 'string':
666
+ case 'text':
667
+ case 'readonly':
668
+ if (typeof searchValue === 'string') {
669
+ // escape single quotes by doubling them
670
+ searchValue = searchValue.replace(/'/g, `''`);
671
+ }
672
+ break;
673
+ case 'integer':
674
+ case 'number':
675
+ case 'float':
676
+ if (typeof searchValue === 'string') {
677
+ // Parse a valid decimal from the string.
678
+ searchValue = searchValue.replace(/\.{2,}/g, '.'); // Replace double dots with single dot
679
+ searchValue = searchValue.replace(/\.+$/g, ''); // Remove trailing dot(s)
680
+ searchValue = searchValue.replace(/^\.+/g, '0.'); // Prefix leading dot with 0.
681
+ searchValue = searchValue.replace(/^-+\.+/g, '-0.'); // Prefix leading dash dot with -0.
682
+ searchValue = searchValue.replace(/(?!^-)[^\d.]/g, ''); // Remove non valid decimal chars
683
+ if (searchValue === '' || searchValue === '-') {
684
+ searchValue = '0';
685
+ }
686
+ }
687
+ break;
688
+ }
689
+ return searchValue;
690
+ }
691
+ }