@carbonorm/carbonnode 3.10.0 → 3.11.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.
@@ -1342,7 +1342,7 @@ export const TABLES = {
1342
1342
  };
1343
1343
  export const C6 = {
1344
1344
  ...C6Constants,
1345
- C6VERSION: '3.10.0',
1345
+ C6VERSION: '3.11.0',
1346
1346
  IMPORT: async (tableName) => {
1347
1347
  tableName = tableName.toLowerCase();
1348
1348
  // if tableName is not a key in the TABLES object then throw an error
@@ -2009,7 +2009,7 @@ export type RestTableInterfaces = iActor
2009
2009
 
2010
2010
  export const C6 : iC6Object<RestTableInterfaces> = {
2011
2011
  ...C6Constants,
2012
- C6VERSION: '3.10.0',
2012
+ C6VERSION: '3.11.0',
2013
2013
  IMPORT: async (tableName: string) : Promise<iDynamicApiImport> => {
2014
2014
 
2015
2015
  tableName = tableName.toLowerCase();
@@ -9,6 +9,7 @@ const Property_Units = {
9
9
  UNIT_ID: 'property_units.unit_id',
10
10
  LOCATION: 'property_units.location',
11
11
  PARCEL_ID: 'property_units.parcel_id',
12
+ COUNTY_ID: 'property_units.county_id',
12
13
  } as const;
13
14
 
14
15
  const Parcel_Sales = {
@@ -19,6 +20,13 @@ const Parcel_Sales = {
19
20
  SALE_DATE: 'parcel_sales.sale_date',
20
21
  } as const;
21
22
 
23
+ const Parcel_Building_Details = {
24
+ TABLE_NAME: 'parcel_building_details',
25
+ PARCEL_ID: 'parcel_building_details.parcel_id',
26
+ YEAR_BUILT: 'parcel_building_details.year_built',
27
+ GLA: 'parcel_building_details.gla',
28
+ } as const;
29
+
22
30
  /**
23
31
  * Complex SELECT coverage focused on WHERE operators, JOIN chains, ORDER, and pagination.
24
32
  */
@@ -479,4 +487,81 @@ describe('SQL Builders - Complex SELECTs', () => {
479
487
  expect(sql).toContain('WHERE ( property_units.unit_id IN (SELECT parcel_sales.parcel_id');
480
488
  expect(params).toContain(5000);
481
489
  });
490
+
491
+ it('serializes spatial filtering with FORCE INDEX and correlated EXISTS subqueries', () => {
492
+ const config = buildParcelConfig();
493
+ const polygon = 'POLYGON((39.5185659 -105.0142915, 39.5401859 -105.0142915, 39.5401859 -104.9862115, 39.5185659 -104.9862115, 39.5185659 -105.0142915))';
494
+ const point = [C6C.ST_GEOMFROMTEXT, ['POINT(39.5293759 -105.0002515)', 4326]];
495
+ const unitId = Buffer.from('11F0615D24861BE1ADD40AFFCF6A1F27', 'hex');
496
+ const countyId = Buffer.from('11F012CFF561A29DBB0E0AFFF25F1747', 'hex');
497
+
498
+ const qb = new SelectQueryBuilder(config as any, {
499
+ [C6C.SELECT]: ['property_units.*'],
500
+ [C6C.INDEX_HINTS]: {
501
+ [C6C.FORCE_INDEX]: ['idx_county_id', 'idx_property_units_location'],
502
+ },
503
+ [C6C.WHERE]: {
504
+ [Property_Units.UNIT_ID]: [C6C.NOT_EQUAL, unitId],
505
+ [Property_Units.COUNTY_ID]: countyId,
506
+ [C6C.MBRCONTAINS]: [
507
+ [C6C.ST_GEOMFROMTEXT, [polygon, 4326]],
508
+ Property_Units.LOCATION,
509
+ ],
510
+ [C6C.LESS_THAN_OR_EQUAL_TO]: [
511
+ [C6C.ST_DISTANCE_SPHERE, Property_Units.LOCATION, point],
512
+ 1200,
513
+ ],
514
+ [C6C.EXISTS]: [
515
+ [
516
+ Property_Units.PARCEL_ID,
517
+ {
518
+ [C6C.SUBSELECT]: {
519
+ [C6C.SELECT]: [Parcel_Building_Details.PARCEL_ID],
520
+ [C6C.FROM]: Parcel_Building_Details.TABLE_NAME,
521
+ [C6C.WHERE]: {
522
+ [Parcel_Building_Details.YEAR_BUILT]: [C6C.BETWEEN, [1988, 2008]],
523
+ [Parcel_Building_Details.GLA]: [C6C.BETWEEN, [1876.5, 3127.5]],
524
+ },
525
+ },
526
+ },
527
+ ],
528
+ [
529
+ Property_Units.PARCEL_ID,
530
+ {
531
+ [C6C.SUBSELECT]: {
532
+ [C6C.SELECT]: [Parcel_Sales.PARCEL_ID],
533
+ [C6C.FROM]: Parcel_Sales.TABLE_NAME,
534
+ [C6C.WHERE]: {
535
+ [Parcel_Sales.SALE_DATE]: [C6C.BETWEEN, ['2023-01-01', '2024-06-30']],
536
+ [Parcel_Sales.SALE_PRICE]: [C6C.NOT_EQUAL, 0],
537
+ },
538
+ },
539
+ },
540
+ ],
541
+ ],
542
+ },
543
+ [C6C.PAGINATION]: {
544
+ [C6C.LIMIT]: 100,
545
+ [C6C.ORDER]: {
546
+ [C6C.ST_DISTANCE_SPHERE]: [Property_Units.LOCATION, point],
547
+ },
548
+ },
549
+ } as any, false);
550
+
551
+ const { sql, params } = qb.build(Property_Units.TABLE_NAME);
552
+
553
+ expect(sql).toContain('FORCE INDEX (`idx_county_id`, `idx_property_units_location`)');
554
+ expect(sql).toMatch(/MBRCONTAINS\(ST_GEOMFROMTEXT\('POLYGON\(\(39\.5185659 -105\.0142915, 39\.5401859 -105\.0142915, 39\.5401859 -104\.9862115, 39\.5185659 -104\.9862115, 39\.5185659 -105\.0142915\)\)', 4326\), property_units\.location\)/);
555
+ expect(sql).toMatch(/ST_DISTANCE_SPHERE\(property_units\.location, ST_GEOMFROMTEXT\('POINT\(39\.5293759 -105\.0002515\)', 4326\)\) <= \?/);
556
+ expect(sql).toMatch(/\(parcel_building_details\.parcel_id\) = property_units\.parcel_id/);
557
+ expect(sql).toMatch(/\(parcel_sales\.parcel_id\) = property_units\.parcel_id/);
558
+ expect(sql).toMatch(/ORDER BY ST[_]Distance[_]Sphere\(property_units\.location, ST_GEOMFROMTEXT\('POINT\(39\.5293759 -105\.0002515\)', 4326\)\)/i);
559
+
560
+ expect(params).toHaveLength(10);
561
+ expect(params[0]).toEqual(unitId);
562
+ expect(params[1]).toEqual(countyId);
563
+ expect(params).toContain(1200);
564
+ expect(params.slice(3, 7)).toEqual([1988, 2008, 1876.5, 3127.5]);
565
+ expect(params.slice(7)).toEqual(['2023-01-01', '2024-06-30', 0]);
566
+ });
482
567
  });
@@ -1,6 +1,4 @@
1
-
2
1
  export const C6Constants = {
3
-
4
2
  // try to 1=1 match the Rest abstract class
5
3
  ADDDATE: 'ADDDATE',
6
4
  ADDTIME: 'ADDTIME',
@@ -34,11 +32,13 @@ export const C6Constants = {
34
32
  DESC: 'DESC',
35
33
  DISTINCT: 'DISTINCT',
36
34
 
35
+ EXISTS: 'EXISTS',
37
36
  EXTRACT: 'EXTRACT',
38
37
  EQUAL: '=',
39
38
  EQUAL_NULL_SAFE: '<=>',
40
39
 
41
40
  FALSE: 'FALSE',
41
+ FORCE: 'FORCE',
42
42
  FULL_OUTER: 'FULL_OUTER',
43
43
  FROM_DAYS: 'FROM_DAYS',
44
44
  FROM_UNIXTIME: 'FROM_UNIXTIME',
@@ -57,6 +57,7 @@ export const C6Constants = {
57
57
  HOUR_MINUTE: 'HOUR_MINUTE',
58
58
 
59
59
  IN: 'IN',
60
+ INDEX: 'INDEX',
60
61
  IS: 'IS',
61
62
  IS_NOT: 'IS_NOT',
62
63
  INNER: 'INNER',
@@ -77,6 +78,7 @@ export const C6Constants = {
77
78
  MAKEDATE: 'MAKEDATE',
78
79
  MAKETIME: 'MAKETIME',
79
80
  MATCH_AGAINST: 'MATCH_AGAINST',
81
+ MBRCONTAINS: 'MBRContains',
80
82
  MONTHNAME: 'MONTHNAME',
81
83
  MICROSECOND: 'MICROSECOND',
82
84
  MINUTE: 'MINUTE',
@@ -95,8 +97,16 @@ export const C6Constants = {
95
97
  ORDER: 'ORDER',
96
98
  OR: 'OR',
97
99
 
100
+ INDEX_HINTS: 'INDEX_HINTS',
101
+
102
+ FORCE_INDEX: 'FORCE INDEX',
103
+ USE_INDEX: 'USE INDEX',
104
+ IGNORE_INDEX: 'IGNORE INDEX',
105
+
98
106
  PAGE: 'PAGE',
99
107
  PAGINATION: 'PAGINATION',
108
+ POLYGON: 'POLYGON',
109
+ POINT: 'POINT',
100
110
  RIGHT_OUTER: 'RIGHT_OUTER',
101
111
 
102
112
  SECOND: 'SECOND',
@@ -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,143 @@ export abstract class ConditionBuilder<
365
378
  throw new Error('Unsupported operand type in SQL expression.');
366
379
  }
367
380
 
381
+ private ensurePlainObject<T>(value: T): any {
382
+ if (value instanceof Map) {
383
+ return Object.fromEntries(value as unknown as Map<string, any>);
384
+ }
385
+ return value;
386
+ }
387
+
388
+ private resolveExistsInnerColumn(subRequest: Record<string, any>, provided?: string): string {
389
+ if (provided) {
390
+ if (typeof provided !== 'string' || provided.trim() === '') {
391
+ throw new Error('EXISTS correlation column must be a non-empty string.');
392
+ }
393
+ return provided;
394
+ }
395
+
396
+ const selectClause = this.ensurePlainObject(subRequest?.[C6C.SELECT]);
397
+ if (Array.isArray(selectClause) && selectClause.length > 0) {
398
+ const candidate = selectClause[0];
399
+ if (typeof candidate === 'string' && candidate.trim() !== '') {
400
+ return candidate;
401
+ }
402
+ }
403
+
404
+ const fromTable = subRequest?.[C6C.FROM];
405
+ if (typeof fromTable === 'string' && fromTable.trim() !== '') {
406
+ const table = this.config.C6?.TABLES?.[fromTable.trim()];
407
+ const primary = table?.PRIMARY;
408
+ if (Array.isArray(primary) && primary.length > 0) {
409
+ return String(primary[0]);
410
+ }
411
+ }
412
+
413
+ throw new Error('EXISTS requires a correlation column to be provided or inferable from the subselect.');
414
+ }
415
+
416
+ private normalizeExistsSpec(
417
+ spec: any
418
+ ): { outerColumn: string; subRequest: Record<string, any>; innerColumn?: string } {
419
+ const normalized = this.ensurePlainObject(spec);
420
+
421
+ if (!Array.isArray(normalized) || normalized.length < 2) {
422
+ throw new Error('EXISTS expects an array like [outerColumn, subselect, innerColumn?].');
423
+ }
424
+
425
+ const [outerRaw, payloadRaw, innerRaw] = normalized;
426
+ if (typeof outerRaw !== 'string' || outerRaw.trim() === '') {
427
+ throw new Error('EXISTS requires the first element to be an outer column reference string.');
428
+ }
429
+
430
+ const payload = this.ensurePlainObject(payloadRaw);
431
+ let subSelect: any;
432
+ if (payload && typeof payload === 'object' && C6C.SUBSELECT in payload) {
433
+ subSelect = this.ensurePlainObject(payload[C6C.SUBSELECT]);
434
+ } else if (payload && typeof payload === 'object') {
435
+ subSelect = payload;
436
+ } else {
437
+ throw new Error('EXISTS requires a subselect payload as the second element.');
438
+ }
439
+
440
+ if (!subSelect || typeof subSelect !== 'object') {
441
+ throw new Error('EXISTS subselect payload must be an object.');
442
+ }
443
+
444
+ const innerColumn = typeof innerRaw === 'string' ? innerRaw : undefined;
445
+
446
+ return {
447
+ outerColumn: outerRaw,
448
+ subRequest: { ...subSelect },
449
+ innerColumn,
450
+ };
451
+ }
452
+
453
+ private buildExistsExpression(
454
+ spec: any,
455
+ operator: string,
456
+ params: any[] | Record<string, any>
457
+ ): string {
458
+ const { outerColumn, subRequest, innerColumn } = this.normalizeExistsSpec(spec);
459
+
460
+ const fromTableRaw = subRequest[C6C.FROM];
461
+ if (typeof fromTableRaw !== 'string' || fromTableRaw.trim() === '') {
462
+ throw new Error('EXISTS subselect requires a table specified with C6C.FROM.');
463
+ }
464
+ const fromTable = fromTableRaw.trim();
465
+
466
+ this.assertValidIdentifier(outerColumn, 'EXISTS correlation column');
467
+ const correlationColumn = this.resolveExistsInnerColumn(subRequest, innerColumn);
468
+ if (!this.isColumnRef(correlationColumn) && !this.isTableReference(correlationColumn)) {
469
+ throw new Error(`Unknown column reference '${correlationColumn}' used in EXISTS subquery correlation column.`);
470
+ }
471
+
472
+ const existingWhereRaw = this.ensurePlainObject(subRequest[C6C.WHERE]);
473
+ const correlationCondition = { [correlationColumn]: [C6C.EQUAL, outerColumn] };
474
+
475
+ const normalizedExistingWhere = existingWhereRaw && typeof existingWhereRaw === 'object'
476
+ ? Array.isArray(existingWhereRaw)
477
+ ? existingWhereRaw.slice()
478
+ : { ...(existingWhereRaw as Record<string, any>) }
479
+ : existingWhereRaw;
480
+
481
+ const hasExistingWhere = Array.isArray(normalizedExistingWhere)
482
+ ? normalizedExistingWhere.length > 0
483
+ : normalizedExistingWhere && typeof normalizedExistingWhere === 'object'
484
+ ? Object.keys(normalizedExistingWhere).length > 0
485
+ : normalizedExistingWhere != null;
486
+
487
+ let whereClause: any;
488
+ if (!hasExistingWhere) {
489
+ whereClause = correlationCondition;
490
+ } else if (
491
+ normalizedExistingWhere && typeof normalizedExistingWhere === 'object' &&
492
+ Object.keys(normalizedExistingWhere).some(key => this.BOOLEAN_OPERATORS.has(key))
493
+ ) {
494
+ whereClause = { [C6C.AND]: [normalizedExistingWhere, correlationCondition] };
495
+ } else if (normalizedExistingWhere && typeof normalizedExistingWhere === 'object') {
496
+ whereClause = { ...normalizedExistingWhere, ...correlationCondition };
497
+ } else {
498
+ whereClause = { [C6C.AND]: [normalizedExistingWhere, correlationCondition] };
499
+ }
500
+
501
+ const subRequestWithCorrelation = {
502
+ ...subRequest,
503
+ [C6C.FROM]: fromTable,
504
+ [C6C.WHERE]: whereClause,
505
+ [C6C.SELECT]: subRequest[C6C.SELECT] ?? ['1'],
506
+ };
507
+
508
+ const buildScalarSubSelect = (this as any).buildScalarSubSelect;
509
+ if (typeof buildScalarSubSelect !== 'function') {
510
+ throw new Error('EXISTS operator requires SelectQueryBuilder context.');
511
+ }
512
+
513
+ const scalar = buildScalarSubSelect.call(this, subRequestWithCorrelation, params);
514
+ const keyword = operator === 'NOT EXISTS' ? 'NOT EXISTS' : C6C.EXISTS;
515
+ return `${keyword} ${scalar}`;
516
+ }
517
+
368
518
  private buildOperatorExpression(
369
519
  op: string,
370
520
  rawOperands: any,
@@ -373,6 +523,15 @@ export abstract class ConditionBuilder<
373
523
  ): string {
374
524
  const operator = this.formatOperator(op);
375
525
 
526
+ if (operator === C6C.EXISTS || operator === 'NOT EXISTS') {
527
+ const operands = Array.isArray(rawOperands) ? rawOperands : [rawOperands];
528
+ if (!operands.length) {
529
+ throw new Error(`${operator} requires at least one subselect specification.`);
530
+ }
531
+ const clauses = operands.map(spec => this.buildExistsExpression(spec, operator, params));
532
+ return this.joinBooleanParts(clauses, 'AND');
533
+ }
534
+
376
535
  if (operator === C6C.MATCH_AGAINST) {
377
536
  if (!Array.isArray(rawOperands) || rawOperands.length !== 2) {
378
537
  throw new Error('MATCH_AGAINST requires an array of two operands.');
@@ -497,6 +656,20 @@ export abstract class ConditionBuilder<
497
656
  value = Object.fromEntries(value);
498
657
  }
499
658
 
659
+ if (typeof column === 'string') {
660
+ const normalizedColumn = column.trim().toUpperCase();
661
+ if (this.BOOLEAN_FUNCTION_KEYS.has(normalizedColumn)) {
662
+ if (!Array.isArray(value)) {
663
+ throw new Error(`${column} expects an array of arguments.`);
664
+ }
665
+ return this.buildFunctionCall(column, value, params);
666
+ }
667
+ }
668
+
669
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(value)) {
670
+ return this.buildOperatorExpression(C6C.EQUAL, [column, value], params, column);
671
+ }
672
+
500
673
  if (Array.isArray(value)) {
501
674
  if (value.length >= 2 && typeof value[0] === 'string') {
502
675
  const [op, ...rest] = value;
@@ -5,12 +5,159 @@ import {resolveDerivedTable, isDerivedTableKey} from "../queryHelpers";
5
5
 
6
6
  export abstract class JoinBuilder<G extends OrmGenerics> extends ConditionBuilder<G>{
7
7
 
8
+ private indexHintCache?: Map<string, string>;
9
+
8
10
  protected createSelectBuilder(
9
11
  _request: any
10
12
  ): { build(table: string, isSubSelect: boolean): { sql: string; params: any[] | Record<string, any> } } {
11
13
  throw new Error('Subclasses must implement createSelectBuilder to support derived table serialization.');
12
14
  }
13
15
 
16
+ protected resetIndexHints(): void {
17
+ this.indexHintCache = undefined;
18
+ }
19
+
20
+ private normalizeIndexHintKey(key: string): string {
21
+ return key
22
+ .replace(/`/g, '')
23
+ .replace(/_/g, ' ')
24
+ .trim()
25
+ .replace(/\s+/g, ' ')
26
+ .toUpperCase();
27
+ }
28
+
29
+ private normalizeHintTargetKey(target: string): string {
30
+ return target.replace(/`/g, '').trim();
31
+ }
32
+
33
+ private hasIndexHintKeys(obj: Record<string, any>): boolean {
34
+ const keys = Object.keys(obj ?? {});
35
+ if (!keys.length) return false;
36
+
37
+ const forceKey = this.normalizeIndexHintKey(C6C.FORCE_INDEX);
38
+ const useKey = this.normalizeIndexHintKey(C6C.USE_INDEX);
39
+ const ignoreKey = this.normalizeIndexHintKey(C6C.IGNORE_INDEX);
40
+
41
+ return keys.some(key => {
42
+ const normalized = this.normalizeIndexHintKey(key);
43
+ return normalized === forceKey || normalized === useKey || normalized === ignoreKey;
44
+ });
45
+ }
46
+
47
+ private normalizeHintSpec(spec: any): Record<string, any> | undefined {
48
+ if (spec instanceof Map) {
49
+ spec = Object.fromEntries(spec);
50
+ }
51
+
52
+ if (Array.isArray(spec) || typeof spec === 'string') {
53
+ return { [C6C.FORCE_INDEX]: spec } as Record<string, any>;
54
+ }
55
+
56
+ if (!spec || typeof spec !== 'object') {
57
+ return undefined;
58
+ }
59
+
60
+ if (!this.hasIndexHintKeys(spec as Record<string, any>)) {
61
+ return undefined;
62
+ }
63
+
64
+ return spec as Record<string, any>;
65
+ }
66
+
67
+ private formatIndexHintClause(spec: any): string {
68
+ const normalizedSpec = this.normalizeHintSpec(spec);
69
+ if (!normalizedSpec) return '';
70
+
71
+ const clauses: string[] = [];
72
+ const forceKey = this.normalizeIndexHintKey(C6C.FORCE_INDEX);
73
+ const useKey = this.normalizeIndexHintKey(C6C.USE_INDEX);
74
+ const ignoreKey = this.normalizeIndexHintKey(C6C.IGNORE_INDEX);
75
+
76
+ const pushClause = (keyword: string, rawValue: any) => {
77
+ const values = Array.isArray(rawValue) ? rawValue : [rawValue];
78
+ const indexes = values
79
+ .map(value => String(value ?? '').trim())
80
+ .filter(Boolean)
81
+ .map(value => `\`${value.replace(/`/g, '``')}\``);
82
+ if (!indexes.length) return;
83
+ clauses.push(`${keyword} (${indexes.join(', ')})`);
84
+ };
85
+
86
+ for (const [key, rawValue] of Object.entries(normalizedSpec)) {
87
+ const normalizedKey = this.normalizeIndexHintKey(key);
88
+ if (normalizedKey === forceKey) {
89
+ pushClause('FORCE INDEX', rawValue);
90
+ } else if (normalizedKey === useKey) {
91
+ pushClause('USE INDEX', rawValue);
92
+ } else if (normalizedKey === ignoreKey) {
93
+ pushClause('IGNORE INDEX', rawValue);
94
+ }
95
+ }
96
+
97
+ return clauses.join(' ');
98
+ }
99
+
100
+ private normalizeIndexHints(raw: any): Map<string, string> | undefined {
101
+ if (raw instanceof Map) {
102
+ raw = Object.fromEntries(raw);
103
+ }
104
+
105
+ const cache = new Map<string, string>();
106
+
107
+ const addEntry = (target: string, spec: any) => {
108
+ const clause = this.formatIndexHintClause(spec);
109
+ if (!clause) return;
110
+ const normalizedTarget = target === '__base__'
111
+ ? '__base__'
112
+ : this.normalizeHintTargetKey(target);
113
+ cache.set(normalizedTarget, clause);
114
+ };
115
+
116
+ if (Array.isArray(raw) || typeof raw === 'string') {
117
+ addEntry('__base__', raw);
118
+ } else if (raw && typeof raw === 'object') {
119
+ if (this.hasIndexHintKeys(raw as Record<string, any>)) {
120
+ addEntry('__base__', raw);
121
+ } else {
122
+ for (const [key, value] of Object.entries(raw as Record<string, any>)) {
123
+ const normalizedKey = this.normalizeHintTargetKey(key);
124
+ if (!normalizedKey) continue;
125
+ addEntry(normalizedKey, value);
126
+ }
127
+ }
128
+ }
129
+
130
+ return cache.size ? cache : undefined;
131
+ }
132
+
133
+ protected getIndexHintClause(table: string, alias?: string): string {
134
+ if (!this.indexHintCache) {
135
+ const rawHints = (this.request as unknown as Record<string, any> | undefined)?.[C6C.INDEX_HINTS];
136
+ this.indexHintCache = this.normalizeIndexHints(rawHints);
137
+ }
138
+
139
+ const hints = this.indexHintCache;
140
+ if (!hints || hints.size === 0) return '';
141
+
142
+ const normalizedTable = this.normalizeHintTargetKey(table);
143
+ const normalizedAlias = alias ? this.normalizeHintTargetKey(alias) : undefined;
144
+
145
+ const candidates = [
146
+ normalizedAlias,
147
+ normalizedAlias ? `${normalizedTable} ${normalizedAlias}` : undefined,
148
+ normalizedTable,
149
+ '__base__',
150
+ ];
151
+
152
+ for (const candidate of candidates) {
153
+ if (!candidate) continue;
154
+ const clause = hints.get(candidate);
155
+ if (clause) return clause;
156
+ }
157
+
158
+ return '';
159
+ }
160
+
14
161
  buildJoinClauses(joinArgs: any, params: any[] | Record<string, any>): string {
15
162
  let sql = '';
16
163
 
@@ -79,7 +226,9 @@ export abstract class JoinBuilder<G extends OrmGenerics> extends ConditionBuilde
79
226
  if (alias) {
80
227
  this.registerAlias(alias, table);
81
228
  }
82
- const joinSql = alias ? `\`${table}\` AS \`${alias}\`` : `\`${table}\``;
229
+ const hintClause = this.getIndexHintClause(table, alias);
230
+ const baseJoinSql = alias ? `\`${table}\` AS \`${alias}\`` : `\`${table}\``;
231
+ const joinSql = hintClause ? `${baseJoinSql} ${hintClause}` : baseJoinSql;
83
232
  const onClause = this.buildBooleanJoinedConditions(conditions, true, params);
84
233
  sql += ` ${joinKind} JOIN ${joinSql}`;
85
234
  if (onClause) {
@@ -16,6 +16,7 @@ export class SelectQueryBuilder<G extends OrmGenerics> extends PaginationBuilder
16
16
  // reset any previously collected SELECT aliases (from AggregateBuilder)
17
17
  // @ts-ignore
18
18
  if (this.selectAliases && this.selectAliases.clear) this.selectAliases.clear();
19
+ this.resetIndexHints();
19
20
  const args = this.request;
20
21
  this.initAlias(table, args.JOIN);
21
22
  const params = this.useNamedParams ? {} : [];
@@ -25,6 +26,10 @@ export class SelectQueryBuilder<G extends OrmGenerics> extends PaginationBuilder
25
26
  .join(', ');
26
27
 
27
28
  let sql = `SELECT ${selectFields} FROM \`${table}\``;
29
+ const baseIndexHint = this.getIndexHintClause(table);
30
+ if (baseIndexHint) {
31
+ sql += ` ${baseIndexHint}`;
32
+ }
28
33
 
29
34
  if (args.JOIN) {
30
35
  sql += this.buildJoinClauses(args.JOIN, params);