@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.
- package/README.md +11 -39
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/index.d.ts +4 -0
- package/dist/interfaces/index.d.ts.map +1 -0
- package/dist/interfaces/index.js +4 -0
- package/dist/interfaces/index.js.map +1 -0
- package/dist/interfaces/sqlResult.interface.d.ts +6 -0
- package/dist/interfaces/sqlResult.interface.d.ts.map +1 -0
- package/dist/interfaces/sqlResult.interface.js +2 -0
- package/dist/interfaces/sqlResult.interface.js.map +1 -0
- package/dist/interfaces/sqlServiceApi.interface.d.ts +22 -0
- package/dist/interfaces/sqlServiceApi.interface.d.ts.map +1 -0
- package/dist/interfaces/sqlServiceApi.interface.js +2 -0
- package/dist/interfaces/sqlServiceApi.interface.js.map +1 -0
- package/dist/interfaces/sqlServiceOption.interface.d.ts +54 -0
- package/dist/interfaces/sqlServiceOption.interface.d.ts.map +1 -0
- package/dist/interfaces/sqlServiceOption.interface.js +2 -0
- package/dist/interfaces/sqlServiceOption.interface.js.map +1 -0
- package/dist/services/sql.service.d.ts +69 -0
- package/dist/services/sql.service.d.ts.map +1 -0
- package/dist/services/sql.service.js +600 -0
- package/dist/services/sql.service.js.map +1 -0
- package/package.json +44 -7
- package/src/index.ts +2 -0
- package/src/interfaces/index.ts +3 -0
- package/src/interfaces/sqlResult.interface.ts +6 -0
- package/src/interfaces/sqlServiceApi.interface.ts +27 -0
- package/src/interfaces/sqlServiceOption.interface.ts +61 -0
- package/src/services/__tests__/sql.service.spec.ts +1819 -0
- 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
|
+
}
|