@carbonorm/carbonnode 3.10.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/api/C6Constants.d.ts +20 -0
  2. package/dist/api/orm/builders/ConditionBuilder.d.ts +8 -0
  3. package/dist/api/orm/builders/JoinBuilder.d.ts +9 -0
  4. package/dist/api/orm/queries/PostQueryBuilder.d.ts +1 -1
  5. package/dist/api/restOrm.d.ts +4 -4
  6. package/dist/api/types/ormInterfaces.d.ts +32 -12
  7. package/dist/api/utils/cacheManager.d.ts +7 -8
  8. package/dist/index.cjs.js +566 -142
  9. package/dist/index.cjs.js.map +1 -1
  10. package/dist/index.esm.js +562 -141
  11. package/dist/index.esm.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/__tests__/cacheManager.test.ts +67 -0
  14. package/src/__tests__/expressServer.e2e.test.ts +104 -2
  15. package/src/__tests__/fixtures/c6.fixture.ts +5 -0
  16. package/src/__tests__/httpExecutorSingular.e2e.test.ts +35 -4
  17. package/src/__tests__/sakila-db/C6.js +1 -1
  18. package/src/__tests__/sakila-db/C6.ts +1 -1
  19. package/src/__tests__/sqlBuilders.complex.test.ts +85 -0
  20. package/src/__tests__/sqlBuilders.test.ts +28 -0
  21. package/src/api/C6Constants.ts +12 -2
  22. package/src/api/axiosInstance.ts +29 -0
  23. package/src/api/executors/HttpExecutor.ts +73 -97
  24. package/src/api/handlers/ExpressHandler.ts +30 -7
  25. package/src/api/orm/builders/ConditionBuilder.ts +227 -0
  26. package/src/api/orm/builders/JoinBuilder.ts +150 -1
  27. package/src/api/orm/queries/PostQueryBuilder.ts +4 -2
  28. package/src/api/orm/queries/SelectQueryBuilder.ts +5 -0
  29. package/src/api/orm/queries/UpdateQueryBuilder.ts +3 -1
  30. package/src/api/types/ormInterfaces.ts +32 -18
  31. package/src/api/utils/cacheManager.ts +75 -34
  32. package/src/api/utils/testHelpers.ts +5 -3
  33. package/src/variables/isNode.ts +1 -8
@@ -8,14 +8,13 @@ import {OrmGenerics} from "../types/ormGenerics";
8
8
  import {
9
9
  DELETE, DetermineResponseDataType,
10
10
  GET,
11
- iCacheAPI,
12
11
  iConstraint,
13
- iGetC6RestResponse,
12
+ C6RestResponse,
14
13
  POST,
15
14
  PUT, RequestQueryBody
16
15
  } from "../types/ormInterfaces";
17
16
  import {removeInvalidKeys, removePrefixIfExists, TestRestfulResponse} from "../utils/apiHelpers";
18
- import {apiRequestCache, checkCache, userCustomClearCache} from "../utils/cacheManager";
17
+ import {checkCache, setCache, userCustomClearCache} from "../utils/cacheManager";
19
18
  import {sortAndSerializeQueryObject} from "../utils/sortAndSerializeQueryObject";
20
19
  import {Executor} from "./Executor";
21
20
  import {toastOptions, toastOptionsDevs} from "variables/toastOptions";
@@ -27,8 +26,11 @@ export class HttpExecutor<
27
26
 
28
27
  private isRestResponse<T extends Record<string, any>>(
29
28
  r: AxiosResponse<any>
30
- ): r is AxiosResponse<iGetC6RestResponse<T>> {
31
- return !!r && r.data != null && typeof r.data === 'object' && 'rest' in r.data;
29
+ ): r is AxiosResponse<C6RestResponse<'GET', T>> {
30
+ return !!r
31
+ && r.data != null
32
+ && typeof r.data === 'object'
33
+ && Array.isArray((r.data as C6RestResponse<'GET', T>).rest);
32
34
  }
33
35
 
34
36
  private stripTableNameFromKeys<T extends Record<string, any>>(obj: Partial<T> | undefined | null): Partial<T> {
@@ -182,20 +184,6 @@ export class HttpExecutor<
182
184
  console.groupEnd()
183
185
  }
184
186
 
185
- // an undefined query would indicate queryCallback returned undefined,
186
- // thus the request shouldn't fire as is in custom cache
187
- if (undefined === this.request || null === this.request) {
188
-
189
- if (isLocal()) {
190
- console.groupCollapsed(`API: (${requestMethod}) (${tableName}) query undefined/null → returning null`)
191
- console.log('request', this.request)
192
- console.groupEnd()
193
- }
194
-
195
- return null;
196
-
197
- }
198
-
199
187
  let query = this.request;
200
188
 
201
189
  // this is parameterless and could return itself with a new page number, or undefined if the end is reached
@@ -219,14 +207,6 @@ export class HttpExecutor<
219
207
  console.groupEnd()
220
208
  }
221
209
 
222
- // The problem with creating cache keys with a stringified object is the order of keys matters and it's possible for the same query to be stringified differently.
223
- // Here we ensure the key order will be identical between two of the same requests. https://stackoverflow.com/questions/5467129/sort-javascript-object-by-key
224
-
225
- // literally impossible for query to be undefined or null here but the editor is too busy licking windows to understand that
226
- let querySerialized: string = sortAndSerializeQueryObject(tables, query ?? {});
227
-
228
- let cacheResult: iCacheAPI | undefined = apiRequestCache.find(cache => cache.requestArgumentsSerialized === querySerialized);
229
-
230
210
  let cachingConfirmed = false;
231
211
 
232
212
  // determine if we need to paginate.
@@ -253,52 +233,42 @@ export class HttpExecutor<
253
233
 
254
234
  query[C6.PAGINATION][C6.LIMIT] = query[C6.PAGINATION][C6.LIMIT] || 100;
255
235
 
256
- // this will evaluate true most the time
257
- if (true === cacheResults) {
258
- if (undefined !== cacheResult) {
259
- do {
260
- const cacheCheck = checkCache<ResponseDataType>(cacheResult, requestMethod, tableName, this.request);
261
- if (false !== cacheCheck) {
262
- return (await cacheCheck).data;
263
- }
264
- ++query[C6.PAGINATION][C6.PAGE];
265
- querySerialized = sortAndSerializeQueryObject(tables, query ?? {});
266
- cacheResult = apiRequestCache.find(cache => cache.requestArgumentsSerialized === querySerialized)
267
- } while (undefined !== cacheResult)
268
- if (debug && isLocal()) {
269
- toast.warning("DEVS: Request pages exhausted in cache; firing network.", toastOptionsDevs);
270
- }
271
- }
272
- cachingConfirmed = true;
273
- } else {
274
- if (debug && isLocal()) toast.info("DEVS: Ignore cache was set to true.", toastOptionsDevs);
275
- }
276
-
277
- if (debug && isLocal()) {
278
- toast.success("DEVS: Request not in cache." + (requestMethod === C6.GET ? " Page (" + query[C6.PAGINATION][C6.PAGE] + ")" : ''), toastOptionsDevs);
279
- }
236
+ }
280
237
 
281
- } else if (cacheResults) { // if we are not getting, we are updating, deleting, or inserting
238
+ // The problem with creating cache keys with a stringified object is the order of keys matters and it's possible for the same query to be stringified differently.
239
+ // Here we ensure the key order will be identical between two of the same requests. https://stackoverflow.com/questions/5467129/sort-javascript-object-by-key
240
+ const cacheRequestData = JSON.parse(JSON.stringify(query ?? {})) as RequestQueryBody<
241
+ G['RequestMethod'],
242
+ G['RestTableInterface'],
243
+ G['CustomAndRequiredFields'],
244
+ G['RequestTableOverrides']
245
+ >;
282
246
 
283
- if (cacheResult) {
284
- const cacheCheck = checkCache<ResponseDataType>(cacheResult, requestMethod, tableName, this.request);
247
+ // literally impossible for query to be undefined or null here but the editor is too busy licking windows to understand that
248
+ let querySerialized: string = sortAndSerializeQueryObject(tables, cacheRequestData ?? {});
285
249
 
286
- if (false !== cacheCheck) {
250
+ let cachedRequest: AxiosPromise<ResponseDataType> | false = false;
287
251
 
288
- return (await cacheCheck).data;
252
+ if (cacheResults) {
253
+ cachedRequest = checkCache<ResponseDataType>(requestMethod, tableName, cacheRequestData);
254
+ }
289
255
 
290
- }
291
- }
256
+ if (cachedRequest) {
257
+ return (await cachedRequest).data;
258
+ }
292
259
 
260
+ if (cacheResults) {
293
261
  cachingConfirmed = true;
294
- // push to cache so we do not repeat the request
262
+ } else if (debug && isLocal()) {
263
+ toast.info("DEVS: Ignore cache was set to true.", toastOptionsDevs);
264
+ }
295
265
 
266
+ if (cacheResults && debug && isLocal()) {
267
+ toast.success("DEVS: Request not in cache." + (requestMethod === C6.GET ? " Page (" + query[C6.PAGINATION][C6.PAGE] + ")" : ''), toastOptionsDevs);
296
268
  }
297
269
 
298
270
  let apiResponse: G['RestTableInterface'][G['PrimaryKey']] | string | boolean | number | undefined;
299
271
 
300
- let returnGetNextPageFunction = false;
301
-
302
272
  let restRequestUri: string = restURL + operatingTable + '/';
303
273
 
304
274
  const needsConditionOrPrimaryCheck = (PUT === requestMethod || DELETE === requestMethod)
@@ -460,22 +430,30 @@ export class HttpExecutor<
460
430
 
461
431
 
462
432
  if (cachingConfirmed) {
463
-
464
- // push to cache so we do not repeat the request
465
- apiRequestCache.push({
433
+ setCache<ResponseDataType>(requestMethod, tableName, cacheRequestData, {
466
434
  requestArgumentsSerialized: querySerialized,
467
- request: axiosActiveRequest
435
+ request: axiosActiveRequest,
468
436
  });
469
-
470
437
  }
471
438
 
472
439
  // returning the promise with this then is important for tests. todo - we could make that optional.
473
440
  // https://rapidapi.com/guides/axios-async-await
474
441
  return axiosActiveRequest.then(async (response: AxiosResponse<ResponseDataType, any>): Promise<AxiosResponse<ResponseDataType, any>> => {
475
442
 
443
+ let hasNext: boolean | undefined;
444
+
476
445
  // noinspection SuspiciousTypeOfGuard
477
446
  if (typeof response.data === 'string') {
478
447
 
448
+ if (cachingConfirmed) {
449
+ setCache<ResponseDataType>(requestMethod, tableName, cacheRequestData, {
450
+ requestArgumentsSerialized: querySerialized,
451
+ request: axiosActiveRequest,
452
+ response,
453
+ final: true,
454
+ });
455
+ }
456
+
479
457
  if (isTest()) {
480
458
 
481
459
  console.trace()
@@ -489,15 +467,11 @@ export class HttpExecutor<
489
467
  }
490
468
 
491
469
  if (cachingConfirmed) {
492
-
493
- const cacheIndex = apiRequestCache.findIndex(cache => cache.requestArgumentsSerialized === querySerialized);
494
-
495
- // TODO - currently nonthing is setting this correctly
496
- apiRequestCache[cacheIndex].final = false === returnGetNextPageFunction
497
-
498
- // only cache get method requests
499
- apiRequestCache[cacheIndex].response = response
500
-
470
+ setCache<ResponseDataType>(requestMethod, tableName, cacheRequestData, {
471
+ requestArgumentsSerialized: querySerialized,
472
+ request: axiosActiveRequest,
473
+ response,
474
+ });
501
475
  }
502
476
 
503
477
  this.runLifecycleHooks<"afterExecution">(
@@ -549,27 +523,30 @@ export class HttpExecutor<
549
523
  callback();
550
524
  }
551
525
 
552
- if (C6.GET === requestMethod && this.isRestResponse<any>(response)) {
526
+ if (C6.GET === requestMethod && this.isRestResponse(response)) {
553
527
 
554
528
  const responseData = response.data;
555
529
 
556
530
  const pageLimit = query?.[C6.PAGINATION]?.[C6.LIMIT];
531
+
557
532
  const got = responseData.rest.length;
558
- const hasNext = pageLimit !== 1 && got === pageLimit;
533
+ hasNext = pageLimit !== 1 && got === pageLimit;
559
534
 
560
535
  if (hasNext) {
561
- responseData.next = apiRequest; // there might be more
536
+ responseData.next = apiRequest as () => Promise<
537
+ DetermineResponseDataType<'GET', G['RestTableInterface']>
538
+ >;
562
539
  } else {
563
540
  responseData.next = undefined; // short page => done
564
541
  }
565
542
 
566
- // If you keep this flag, make it reflect reality:
567
- returnGetNextPageFunction = hasNext;
568
-
569
- // and fix cache ‘final’ flag to match:
570
543
  if (cachingConfirmed) {
571
- const cacheIndex = apiRequestCache.findIndex(c => c.requestArgumentsSerialized === querySerialized);
572
- apiRequestCache[cacheIndex].final = !hasNext;
544
+ setCache<ResponseDataType>(requestMethod, tableName, cacheRequestData, {
545
+ requestArgumentsSerialized: querySerialized,
546
+ request: axiosActiveRequest,
547
+ response,
548
+ final: !hasNext,
549
+ });
573
550
  }
574
551
 
575
552
  if ((this.config.verbose || debug) && isLocal()) {
@@ -580,11 +557,9 @@ export class HttpExecutor<
580
557
  }
581
558
 
582
559
  // next already set above based on hasNext; avoid duplicate, inverted logic
583
-
584
-
585
560
  if (fetchDependencies
586
561
  && 'number' === typeof fetchDependencies
587
- && responseData.rest.length > 0) {
562
+ && responseData.rest?.length > 0) {
588
563
 
589
564
  console.groupCollapsed('%c API: Fetch Dependencies segment (' + requestMethod + ' ' + tableName + ')'
590
565
  + (fetchDependencies & eFetchDependencies.CHILDREN ? ' | (CHILDREN|REFERENCED) ' : '')
@@ -827,6 +802,15 @@ export class HttpExecutor<
827
802
 
828
803
  }
829
804
 
805
+ if (cachingConfirmed && hasNext === undefined) {
806
+ setCache<ResponseDataType>(requestMethod, tableName, cacheRequestData, {
807
+ requestArgumentsSerialized: querySerialized,
808
+ request: axiosActiveRequest,
809
+ response,
810
+ final: true,
811
+ });
812
+ }
813
+
830
814
  if (debug && isLocal()) {
831
815
 
832
816
  toast.success("DEVS: (" + requestMethod + ") request complete.", toastOptionsDevs);
@@ -841,25 +825,17 @@ export class HttpExecutor<
841
825
 
842
826
  } catch (throwableError) {
843
827
 
844
- if (isTest()) {
845
-
846
- throw new Error(JSON.stringify(throwableError))
847
-
848
- }
849
-
850
828
  console.groupCollapsed('%c API: An error occurred in the try catch block. returning null!', 'color: #ff0000')
851
829
 
852
830
  console.log('%c ' + requestMethod + ' ' + tableName, 'color: #A020F0')
853
831
 
854
- console.warn(throwableError)
832
+ console.error(throwableError)
855
833
 
856
834
  console.trace()
857
835
 
858
836
  console.groupEnd()
859
837
 
860
- TestRestfulResponse(throwableError, success, error)
861
-
862
- return null;
838
+ throw new Error(JSON.stringify(throwableError))
863
839
 
864
840
  }
865
841
 
@@ -13,7 +13,7 @@ export function ExpressHandler({C6, mysqlPool}: { C6: iC6Object, mysqlPool: Pool
13
13
  try {
14
14
  const incomingMethod = req.method.toUpperCase() as iRestMethods;
15
15
  const table = req.params.table;
16
- const primary = req.params.primary;
16
+ let primary = req.params.primary;
17
17
  // Support Axios interceptor promoting large GETs to POST with ?METHOD=GET
18
18
  const methodOverrideRaw = (req.query?.METHOD ?? req.query?.method) as unknown;
19
19
  const methodOverride = typeof methodOverrideRaw === 'string' ? methodOverrideRaw.toUpperCase() : undefined;
@@ -38,17 +38,40 @@ export function ExpressHandler({C6, mysqlPool}: { C6: iC6Object, mysqlPool: Pool
38
38
  return;
39
39
  }
40
40
 
41
- const primaryKeys = C6.TABLES[table].PRIMARY;
41
+ const restModel = C6.TABLES[table];
42
+ const primaryKeys = restModel.PRIMARY;
43
+ const primaryShortKeys = restModel.PRIMARY_SHORT ?? [];
44
+ const columnMap = restModel.COLUMNS ?? {};
45
+ const resolveShortKey = (fullKey: string, index: number) =>
46
+ (columnMap as any)[fullKey] ?? primaryShortKeys[index] ?? fullKey.split('.').pop() ?? fullKey;
47
+ const hasPrimaryKeyValues = (data: any) => {
48
+ if (!data || typeof data !== 'object') return false;
49
+ const whereClause = (data as any)[C6C.WHERE];
50
+ const hasKeyValue = (obj: any, fullKey: string, shortKey: string) => {
51
+ if (!obj || typeof obj !== 'object') return false;
52
+ const fullValue = obj[fullKey];
53
+ if (fullValue !== undefined && fullValue !== null) return true;
54
+ const shortValue = shortKey ? obj[shortKey] : undefined;
55
+ return shortValue !== undefined && shortValue !== null;
56
+ };
57
+ return primaryKeys.every((fullKey, index) => {
58
+ const shortKey = resolveShortKey(fullKey, index);
59
+ return hasKeyValue(whereClause, fullKey, shortKey) || hasKeyValue(data, fullKey, shortKey);
60
+ });
61
+ };
42
62
 
43
63
  if (primary && primaryKeys.length !== 1) {
44
- if (primaryKeys.length > 1) {
64
+ if (primaryKeys.length > 1 && hasPrimaryKeyValues(payload)) {
65
+ primary = undefined;
66
+ } else if (primaryKeys.length > 1) {
45
67
  res.status(400).json({error: `Table ${table} has multiple primary keys. Cannot implicitly determine key.`});
46
68
  return;
69
+ } else {
70
+ res.status(400).json({
71
+ error: `Table ${table} has no primary keys. Please specify one.`
72
+ });
73
+ return;
47
74
  }
48
- res.status(400).json({
49
- error: `Table ${table} has no primary keys. Please specify one.`
50
- });
51
- return;
52
75
  }
53
76
 
54
77
  const primaryKeyName = primaryKeys[0];
@@ -107,9 +107,18 @@ export abstract class ConditionBuilder<
107
107
  [C6C.BETWEEN, C6C.BETWEEN],
108
108
  ['BETWEEN', C6C.BETWEEN],
109
109
  ['NOT BETWEEN', 'NOT BETWEEN'],
110
+ [C6C.EXISTS, C6C.EXISTS],
111
+ ['EXISTS', C6C.EXISTS],
112
+ ['NOT EXISTS', 'NOT EXISTS'],
110
113
  [C6C.MATCH_AGAINST, C6C.MATCH_AGAINST],
111
114
  ]);
112
115
 
116
+ private readonly BOOLEAN_FUNCTION_KEYS = new Set<string>([
117
+ C6C.ST_CONTAINS?.toUpperCase?.() ?? 'ST_CONTAINS',
118
+ C6C.ST_WITHIN?.toUpperCase?.() ?? 'ST_WITHIN',
119
+ C6C.MBRCONTAINS?.toUpperCase?.() ?? 'MBRCONTAINS',
120
+ ]);
121
+
113
122
  private isTableReference(val: any): boolean {
114
123
  if (typeof val !== 'string') return false;
115
124
  // Support aggregate aliases (e.g., SELECT COUNT(x) AS cnt ... HAVING cnt > 1)
@@ -313,6 +322,10 @@ export abstract class ConditionBuilder<
313
322
  return { sql: asParam(operand), isReference: false, isExpression: false, isSubSelect: false };
314
323
  }
315
324
 
325
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(operand)) {
326
+ return { sql: asParam(operand), isReference: false, isExpression: false, isSubSelect: false };
327
+ }
328
+
316
329
  if (typeof operand === 'string') {
317
330
  if (this.isTableReference(operand) || this.isColumnRef(operand)) {
318
331
  return { sql: operand, isReference: true, isExpression: false, isSubSelect: false };
@@ -365,6 +378,197 @@ export abstract class ConditionBuilder<
365
378
  throw new Error('Unsupported operand type in SQL expression.');
366
379
  }
367
380
 
381
+ private isPlainArrayLiteral(value: any): boolean {
382
+ if (!Array.isArray(value)) return false;
383
+ return value.every(item => {
384
+ if (item === null) return true;
385
+ const type = typeof item;
386
+ if (type === 'string' || type === 'number' || type === 'boolean') return true;
387
+ if (Array.isArray(item)) return this.isPlainArrayLiteral(item);
388
+ if (item && typeof item === 'object') return this.isPlainObjectLiteral(item);
389
+ return false;
390
+ });
391
+ }
392
+
393
+ private isPlainObjectLiteral(value: any): boolean {
394
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
395
+ if (value instanceof Date) return false;
396
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(value)) return false;
397
+
398
+ const normalized = value instanceof Map ? Object.fromEntries(value) : value;
399
+ if (C6C.SUBSELECT in (normalized as any)) return false;
400
+
401
+ const entries = Object.entries(normalized as Record<string, any>);
402
+ if (entries.length === 0) return true;
403
+
404
+ if (entries.some(([key]) => this.isOperator(key) || this.BOOLEAN_OPERATORS.has(key))) {
405
+ return false;
406
+ }
407
+
408
+ if (entries.some(([key]) => typeof key === 'string' && (this.isColumnRef(key) || key.includes('.')))) {
409
+ return false;
410
+ }
411
+
412
+ return true;
413
+ }
414
+
415
+ protected serializeUpdateValue(
416
+ value: any,
417
+ params: any[] | Record<string, any>,
418
+ contextColumn?: string
419
+ ): string {
420
+ const normalized = value instanceof Map ? Object.fromEntries(value) : value;
421
+
422
+ if (this.isPlainArrayLiteral(normalized) || this.isPlainObjectLiteral(normalized)) {
423
+ return this.addParam(params, contextColumn ?? '', JSON.stringify(normalized));
424
+ }
425
+
426
+ const { sql, isReference, isExpression, isSubSelect } = this.serializeOperand(normalized, params, contextColumn);
427
+
428
+ if (!isReference && !isExpression && !isSubSelect && typeof normalized === 'object' && normalized !== null) {
429
+ throw new Error('Unsupported operand type in SQL expression.');
430
+ }
431
+
432
+ return sql;
433
+ }
434
+
435
+ private ensurePlainObject<T>(value: T): any {
436
+ if (value instanceof Map) {
437
+ return Object.fromEntries(value as unknown as Map<string, any>);
438
+ }
439
+ return value;
440
+ }
441
+
442
+ private resolveExistsInnerColumn(subRequest: Record<string, any>, provided?: string): string {
443
+ if (provided) {
444
+ if (typeof provided !== 'string' || provided.trim() === '') {
445
+ throw new Error('EXISTS correlation column must be a non-empty string.');
446
+ }
447
+ return provided;
448
+ }
449
+
450
+ const selectClause = this.ensurePlainObject(subRequest?.[C6C.SELECT]);
451
+ if (Array.isArray(selectClause) && selectClause.length > 0) {
452
+ const candidate = selectClause[0];
453
+ if (typeof candidate === 'string' && candidate.trim() !== '') {
454
+ return candidate;
455
+ }
456
+ }
457
+
458
+ const fromTable = subRequest?.[C6C.FROM];
459
+ if (typeof fromTable === 'string' && fromTable.trim() !== '') {
460
+ const table = this.config.C6?.TABLES?.[fromTable.trim()];
461
+ const primary = table?.PRIMARY;
462
+ if (Array.isArray(primary) && primary.length > 0) {
463
+ return String(primary[0]);
464
+ }
465
+ }
466
+
467
+ throw new Error('EXISTS requires a correlation column to be provided or inferable from the subselect.');
468
+ }
469
+
470
+ private normalizeExistsSpec(
471
+ spec: any
472
+ ): { outerColumn: string; subRequest: Record<string, any>; innerColumn?: string } {
473
+ const normalized = this.ensurePlainObject(spec);
474
+
475
+ if (!Array.isArray(normalized) || normalized.length < 2) {
476
+ throw new Error('EXISTS expects an array like [outerColumn, subselect, innerColumn?].');
477
+ }
478
+
479
+ const [outerRaw, payloadRaw, innerRaw] = normalized;
480
+ if (typeof outerRaw !== 'string' || outerRaw.trim() === '') {
481
+ throw new Error('EXISTS requires the first element to be an outer column reference string.');
482
+ }
483
+
484
+ const payload = this.ensurePlainObject(payloadRaw);
485
+ let subSelect: any;
486
+ if (payload && typeof payload === 'object' && C6C.SUBSELECT in payload) {
487
+ subSelect = this.ensurePlainObject(payload[C6C.SUBSELECT]);
488
+ } else if (payload && typeof payload === 'object') {
489
+ subSelect = payload;
490
+ } else {
491
+ throw new Error('EXISTS requires a subselect payload as the second element.');
492
+ }
493
+
494
+ if (!subSelect || typeof subSelect !== 'object') {
495
+ throw new Error('EXISTS subselect payload must be an object.');
496
+ }
497
+
498
+ const innerColumn = typeof innerRaw === 'string' ? innerRaw : undefined;
499
+
500
+ return {
501
+ outerColumn: outerRaw,
502
+ subRequest: { ...subSelect },
503
+ innerColumn,
504
+ };
505
+ }
506
+
507
+ private buildExistsExpression(
508
+ spec: any,
509
+ operator: string,
510
+ params: any[] | Record<string, any>
511
+ ): string {
512
+ const { outerColumn, subRequest, innerColumn } = this.normalizeExistsSpec(spec);
513
+
514
+ const fromTableRaw = subRequest[C6C.FROM];
515
+ if (typeof fromTableRaw !== 'string' || fromTableRaw.trim() === '') {
516
+ throw new Error('EXISTS subselect requires a table specified with C6C.FROM.');
517
+ }
518
+ const fromTable = fromTableRaw.trim();
519
+
520
+ this.assertValidIdentifier(outerColumn, 'EXISTS correlation column');
521
+ const correlationColumn = this.resolveExistsInnerColumn(subRequest, innerColumn);
522
+ if (!this.isColumnRef(correlationColumn) && !this.isTableReference(correlationColumn)) {
523
+ throw new Error(`Unknown column reference '${correlationColumn}' used in EXISTS subquery correlation column.`);
524
+ }
525
+
526
+ const existingWhereRaw = this.ensurePlainObject(subRequest[C6C.WHERE]);
527
+ const correlationCondition = { [correlationColumn]: [C6C.EQUAL, outerColumn] };
528
+
529
+ const normalizedExistingWhere = existingWhereRaw && typeof existingWhereRaw === 'object'
530
+ ? Array.isArray(existingWhereRaw)
531
+ ? existingWhereRaw.slice()
532
+ : { ...(existingWhereRaw as Record<string, any>) }
533
+ : existingWhereRaw;
534
+
535
+ const hasExistingWhere = Array.isArray(normalizedExistingWhere)
536
+ ? normalizedExistingWhere.length > 0
537
+ : normalizedExistingWhere && typeof normalizedExistingWhere === 'object'
538
+ ? Object.keys(normalizedExistingWhere).length > 0
539
+ : normalizedExistingWhere != null;
540
+
541
+ let whereClause: any;
542
+ if (!hasExistingWhere) {
543
+ whereClause = correlationCondition;
544
+ } else if (
545
+ normalizedExistingWhere && typeof normalizedExistingWhere === 'object' &&
546
+ Object.keys(normalizedExistingWhere).some(key => this.BOOLEAN_OPERATORS.has(key))
547
+ ) {
548
+ whereClause = { [C6C.AND]: [normalizedExistingWhere, correlationCondition] };
549
+ } else if (normalizedExistingWhere && typeof normalizedExistingWhere === 'object') {
550
+ whereClause = { ...normalizedExistingWhere, ...correlationCondition };
551
+ } else {
552
+ whereClause = { [C6C.AND]: [normalizedExistingWhere, correlationCondition] };
553
+ }
554
+
555
+ const subRequestWithCorrelation = {
556
+ ...subRequest,
557
+ [C6C.FROM]: fromTable,
558
+ [C6C.WHERE]: whereClause,
559
+ [C6C.SELECT]: subRequest[C6C.SELECT] ?? ['1'],
560
+ };
561
+
562
+ const buildScalarSubSelect = (this as any).buildScalarSubSelect;
563
+ if (typeof buildScalarSubSelect !== 'function') {
564
+ throw new Error('EXISTS operator requires SelectQueryBuilder context.');
565
+ }
566
+
567
+ const scalar = buildScalarSubSelect.call(this, subRequestWithCorrelation, params);
568
+ const keyword = operator === 'NOT EXISTS' ? 'NOT EXISTS' : C6C.EXISTS;
569
+ return `${keyword} ${scalar}`;
570
+ }
571
+
368
572
  private buildOperatorExpression(
369
573
  op: string,
370
574
  rawOperands: any,
@@ -373,6 +577,15 @@ export abstract class ConditionBuilder<
373
577
  ): string {
374
578
  const operator = this.formatOperator(op);
375
579
 
580
+ if (operator === C6C.EXISTS || operator === 'NOT EXISTS') {
581
+ const operands = Array.isArray(rawOperands) ? rawOperands : [rawOperands];
582
+ if (!operands.length) {
583
+ throw new Error(`${operator} requires at least one subselect specification.`);
584
+ }
585
+ const clauses = operands.map(spec => this.buildExistsExpression(spec, operator, params));
586
+ return this.joinBooleanParts(clauses, 'AND');
587
+ }
588
+
376
589
  if (operator === C6C.MATCH_AGAINST) {
377
590
  if (!Array.isArray(rawOperands) || rawOperands.length !== 2) {
378
591
  throw new Error('MATCH_AGAINST requires an array of two operands.');
@@ -497,6 +710,20 @@ export abstract class ConditionBuilder<
497
710
  value = Object.fromEntries(value);
498
711
  }
499
712
 
713
+ if (typeof column === 'string') {
714
+ const normalizedColumn = column.trim().toUpperCase();
715
+ if (this.BOOLEAN_FUNCTION_KEYS.has(normalizedColumn)) {
716
+ if (!Array.isArray(value)) {
717
+ throw new Error(`${column} expects an array of arguments.`);
718
+ }
719
+ return this.buildFunctionCall(column, value, params);
720
+ }
721
+ }
722
+
723
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(value)) {
724
+ return this.buildOperatorExpression(C6C.EQUAL, [column, value], params, column);
725
+ }
726
+
500
727
  if (Array.isArray(value)) {
501
728
  if (value.length >= 2 && typeof value[0] === 'string') {
502
729
  const [op, ...rest] = value;