@oneuptime/common 11.3.8 → 11.3.10

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 (80) hide show
  1. package/Models/AnalyticsModels/ExceptionInstance.ts +27 -0
  2. package/Models/AnalyticsModels/Log.ts +1 -0
  3. package/Models/AnalyticsModels/Metric.ts +1 -0
  4. package/Models/AnalyticsModels/MutableMetric.ts +333 -0
  5. package/Models/AnalyticsModels/Profile.ts +1 -0
  6. package/Models/AnalyticsModels/Span.ts +1 -0
  7. package/Server/Infrastructure/Queue.ts +19 -2
  8. package/Server/Infrastructure/QueueWorker.ts +67 -1
  9. package/Server/Services/AlertService.ts +167 -206
  10. package/Server/Services/AlertStateTimelineService.ts +121 -1
  11. package/Server/Services/AnalyticsDatabaseService.ts +19 -23
  12. package/Server/Services/IncidentService.ts +179 -252
  13. package/Server/Services/IncidentStateTimelineService.ts +120 -1
  14. package/Server/Services/Index.ts +2 -0
  15. package/Server/Services/MetricAggregationService.ts +73 -3
  16. package/Server/Services/MetricService.ts +228 -0
  17. package/Server/Services/MutableMetricService.ts +275 -0
  18. package/Server/Services/TelemetryAttributeService.ts +186 -5
  19. package/Server/Utils/AnalyticsDatabase/ClusterConfig.ts +2 -0
  20. package/Server/Utils/AnalyticsDatabase/Statement.ts +2 -0
  21. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +81 -2
  22. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +143 -0
  23. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +208 -0
  24. package/Tests/Types/Metrics/MetricPipelineRuleFilterCondition.test.ts +208 -0
  25. package/Tests/Types/Monitor/MonitorStepEntityScope.test.ts +0 -56
  26. package/Types/AnalyticsDatabase/AnalyticsTableEngine.ts +1 -0
  27. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +1 -0
  28. package/Types/AnalyticsDatabase/TableColumn.ts +16 -0
  29. package/Types/Monitor/MonitorStepMetricMonitor.ts +0 -27
  30. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +24 -0
  31. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  32. package/build/dist/Models/AnalyticsModels/Log.js +1 -0
  33. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  34. package/build/dist/Models/AnalyticsModels/Metric.js +1 -0
  35. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  36. package/build/dist/Models/AnalyticsModels/MutableMetric.js +275 -0
  37. package/build/dist/Models/AnalyticsModels/MutableMetric.js.map +1 -0
  38. package/build/dist/Models/AnalyticsModels/Profile.js +1 -0
  39. package/build/dist/Models/AnalyticsModels/Profile.js.map +1 -1
  40. package/build/dist/Models/AnalyticsModels/Span.js +1 -0
  41. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  42. package/build/dist/Server/Infrastructure/Queue.js +14 -2
  43. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  44. package/build/dist/Server/Infrastructure/QueueWorker.js +45 -1
  45. package/build/dist/Server/Infrastructure/QueueWorker.js.map +1 -1
  46. package/build/dist/Server/Services/AlertService.js +133 -170
  47. package/build/dist/Server/Services/AlertService.js.map +1 -1
  48. package/build/dist/Server/Services/AlertStateTimelineService.js +97 -0
  49. package/build/dist/Server/Services/AlertStateTimelineService.js.map +1 -1
  50. package/build/dist/Server/Services/AnalyticsDatabaseService.js +16 -15
  51. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  52. package/build/dist/Server/Services/IncidentService.js +142 -207
  53. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  54. package/build/dist/Server/Services/IncidentStateTimelineService.js +97 -0
  55. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  56. package/build/dist/Server/Services/Index.js +2 -0
  57. package/build/dist/Server/Services/Index.js.map +1 -1
  58. package/build/dist/Server/Services/MetricAggregationService.js +49 -3
  59. package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
  60. package/build/dist/Server/Services/MetricService.js +155 -0
  61. package/build/dist/Server/Services/MetricService.js.map +1 -1
  62. package/build/dist/Server/Services/MutableMetricService.js +182 -0
  63. package/build/dist/Server/Services/MutableMetricService.js.map +1 -0
  64. package/build/dist/Server/Services/TelemetryAttributeService.js +132 -7
  65. package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
  66. package/build/dist/Server/Utils/AnalyticsDatabase/ClusterConfig.js +2 -0
  67. package/build/dist/Server/Utils/AnalyticsDatabase/ClusterConfig.js.map +1 -1
  68. package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js +2 -0
  69. package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js.map +1 -1
  70. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +66 -2
  71. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  72. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableEngine.js +1 -0
  73. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableEngine.js.map +1 -1
  74. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +1 -0
  75. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  76. package/build/dist/Types/AnalyticsDatabase/TableColumn.js +7 -0
  77. package/build/dist/Types/AnalyticsDatabase/TableColumn.js.map +1 -1
  78. package/build/dist/Types/Monitor/MonitorStepMetricMonitor.js +0 -13
  79. package/build/dist/Types/Monitor/MonitorStepMetricMonitor.js.map +1 -1
  80. package/package.json +1 -1
@@ -82,6 +82,38 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
82
82
  this.database = data.database;
83
83
  }
84
84
 
85
+ private appendMapKeyPresenceFilter(input: {
86
+ whereStatement: Statement;
87
+ mapColumn: AnalyticsTableColumn;
88
+ mapKey: string;
89
+ skip?: boolean;
90
+ }): void {
91
+ const mapKeysColumnName: string | undefined = input.mapColumn.mapKeysColumn;
92
+
93
+ if (input.skip || !mapKeysColumnName) {
94
+ return;
95
+ }
96
+
97
+ const mapKeysColumn: AnalyticsTableColumn | null =
98
+ this.model.getTableColumn(mapKeysColumnName);
99
+
100
+ if (!mapKeysColumn || mapKeysColumn.type !== TableColumnType.ArrayText) {
101
+ return;
102
+ }
103
+
104
+ /*
105
+ * Keep empty key-array rows eligible so data written before the
106
+ * denormalized column existed, or by a lagging ingest path, is still
107
+ * checked by the canonical map['key'] predicate that follows.
108
+ */
109
+ input.whereStatement.append(
110
+ SQL`AND (empty(${mapKeysColumn.key}) OR hasAny(${mapKeysColumn.key}, ${{
111
+ value: [input.mapKey],
112
+ type: TableColumnType.ArrayText,
113
+ }})) `,
114
+ );
115
+ }
116
+
85
117
  public toUpdateStatement(updateBy: UpdateBy<TBaseModel>): Statement {
86
118
  const setStatement: Statement = this.toSetStatement(updateBy.data);
87
119
  const whereStatement: Statement = this.toWhereStatement(updateBy.query);
@@ -708,6 +740,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
708
740
  }
709
741
 
710
742
  if (mapEntry instanceof NotNull) {
743
+ this.appendMapKeyPresenceFilter({
744
+ whereStatement,
745
+ mapColumn: tableColumn,
746
+ mapKey,
747
+ });
711
748
  whereStatement.append(
712
749
  SQL`AND mapContains(${key}, ${{
713
750
  value: mapKey,
@@ -789,12 +826,21 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
789
826
  }
790
827
 
791
828
  if (mapEntry instanceof EqualTo) {
829
+ const equalityValue: string = String(
830
+ (mapEntry as EqualTo<any>).value ?? "",
831
+ );
832
+ this.appendMapKeyPresenceFilter({
833
+ whereStatement,
834
+ mapColumn: tableColumn,
835
+ mapKey,
836
+ skip: equalityValue === "",
837
+ });
792
838
  whereStatement.append(
793
839
  SQL`AND ${key}[${{
794
840
  value: mapKey,
795
841
  type: TableColumnType.Text,
796
842
  }}] = ${{
797
- value: String((mapEntry as EqualTo<any>).value ?? ""),
843
+ value: equalityValue,
798
844
  type: TableColumnType.Text,
799
845
  }}`,
800
846
  );
@@ -809,6 +855,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
809
855
  * threshold and naturally drops those rows.
810
856
  */
811
857
  if (mapEntry instanceof GreaterThan) {
858
+ this.appendMapKeyPresenceFilter({
859
+ whereStatement,
860
+ mapColumn: tableColumn,
861
+ mapKey,
862
+ });
812
863
  whereStatement.append(
813
864
  SQL`AND toFloat64OrNull(${key}[${{
814
865
  value: mapKey,
@@ -822,6 +873,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
822
873
  }
823
874
 
824
875
  if (mapEntry instanceof GreaterThanOrEqual) {
876
+ this.appendMapKeyPresenceFilter({
877
+ whereStatement,
878
+ mapColumn: tableColumn,
879
+ mapKey,
880
+ });
825
881
  whereStatement.append(
826
882
  SQL`AND toFloat64OrNull(${key}[${{
827
883
  value: mapKey,
@@ -835,6 +891,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
835
891
  }
836
892
 
837
893
  if (mapEntry instanceof LessThan) {
894
+ this.appendMapKeyPresenceFilter({
895
+ whereStatement,
896
+ mapColumn: tableColumn,
897
+ mapKey,
898
+ });
838
899
  whereStatement.append(
839
900
  SQL`AND toFloat64OrNull(${key}[${{
840
901
  value: mapKey,
@@ -848,6 +909,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
848
909
  }
849
910
 
850
911
  if (mapEntry instanceof LessThanOrEqual) {
912
+ this.appendMapKeyPresenceFilter({
913
+ whereStatement,
914
+ mapColumn: tableColumn,
915
+ mapKey,
916
+ });
851
917
  whereStatement.append(
852
918
  SQL`AND toFloat64OrNull(${key}[${{
853
919
  value: mapKey,
@@ -876,6 +942,12 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
876
942
  if (includesValues.length === 0) {
877
943
  continue;
878
944
  }
945
+ this.appendMapKeyPresenceFilter({
946
+ whereStatement,
947
+ mapColumn: tableColumn,
948
+ mapKey,
949
+ skip: includesValues.includes(""),
950
+ });
879
951
  whereStatement.append(
880
952
  SQL`AND ${key}[${{
881
953
  value: mapKey,
@@ -919,12 +991,19 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
919
991
  }
920
992
 
921
993
  // Bare string/number/boolean — direct Map subscript.
994
+ const equalityValue: string = String(mapEntry);
995
+ this.appendMapKeyPresenceFilter({
996
+ whereStatement,
997
+ mapColumn: tableColumn,
998
+ mapKey,
999
+ skip: equalityValue === "",
1000
+ });
922
1001
  whereStatement.append(
923
1002
  SQL`AND ${key}[${{
924
1003
  value: mapKey,
925
1004
  type: TableColumnType.Text,
926
1005
  }}] = ${{
927
- value: String(mapEntry),
1006
+ value: equalityValue,
928
1007
  type: TableColumnType.Text,
929
1008
  }}`,
930
1009
  );
@@ -13,6 +13,8 @@ import TableColumnType from "../../../Types/AnalyticsDatabase/TableColumnType";
13
13
  import AggregationType from "../../../Types/BaseDatabase/AggregationType";
14
14
  import BadDataException from "../../../Types/Exception/BadDataException";
15
15
  import GenericObject from "../../../Types/GenericObject";
16
+ import ObjectID from "../../../Types/ObjectID";
17
+ import OneUptimeDate from "../../../Types/Date";
16
18
  import {
17
19
  describe,
18
20
  expect,
@@ -379,6 +381,147 @@ describe("AnalyticsDatabaseService", () => {
379
381
  * ids per call. Must be strictly additive: option-less calls keep the
380
382
  * exact pre-existing exec/query payload.
381
383
  */
384
+ /*
385
+ * Required-field validation. A required column is satisfied by any present
386
+ * value (including the boolean `false`); only null/undefined triggers a
387
+ * default-fill or a "<field> is required" error. This is regression coverage
388
+ * for the boolean branch that previously rejected every required Boolean
389
+ * column (both true and false), which made models like MutableMetric — whose
390
+ * `isDeleted` tombstone flag is a required Boolean — impossible to insert.
391
+ */
392
+ describe("checkRequiredFields", () => {
393
+ class RequiredFieldsModel extends AnalyticsBaseModel {
394
+ public constructor() {
395
+ super({
396
+ tableName: "<required-fields-table>",
397
+ singularName: "<singular-name>",
398
+ pluralName: "<plural-name>",
399
+ tableColumns: [
400
+ new AnalyticsTableColumn({
401
+ key: "requiredText",
402
+ title: "<title>",
403
+ description: "<description>",
404
+ required: true,
405
+ type: TableColumnType.Text,
406
+ }),
407
+ new AnalyticsTableColumn({
408
+ key: "requiredBoolWithDefault",
409
+ title: "<title>",
410
+ description: "<description>",
411
+ required: true,
412
+ type: TableColumnType.Boolean,
413
+ defaultValue: false,
414
+ }),
415
+ ],
416
+ crudApiPath: new Route("route"),
417
+ primaryKeys: ["requiredText"],
418
+ sortKeys: ["requiredText"],
419
+ partitionKey: "requiredText",
420
+ tableEngine: AnalyticsTableEngine.MergeTree,
421
+ });
422
+ }
423
+
424
+ // checkRequiredFields reads columns via property accessors, so the model
425
+ // must expose getters/setters that delegate to get/setColumnValue (real
426
+ // analytics models do the same for every column).
427
+ public get requiredText(): string | undefined {
428
+ return this.getColumnValue("requiredText") as string | undefined;
429
+ }
430
+ public set requiredText(v: string | undefined) {
431
+ this.setColumnValue("requiredText", v);
432
+ }
433
+ public get requiredBoolWithDefault(): boolean | undefined {
434
+ return this.getColumnValue("requiredBoolWithDefault") as
435
+ | boolean
436
+ | undefined;
437
+ }
438
+ public set requiredBoolWithDefault(v: boolean | undefined) {
439
+ this.setColumnValue("requiredBoolWithDefault", v);
440
+ }
441
+ }
442
+
443
+ let requiredFieldsService: AnalyticsDatabaseService<RequiredFieldsModel>;
444
+
445
+ beforeEach(() => {
446
+ requiredFieldsService = new AnalyticsDatabaseService({
447
+ modelType: RequiredFieldsModel,
448
+ });
449
+ });
450
+
451
+ // Build a model with every required column satisfied except as overridden.
452
+ const buildModel: (
453
+ overrides?: (model: RequiredFieldsModel) => void,
454
+ ) => RequiredFieldsModel = (
455
+ overrides?: (model: RequiredFieldsModel) => void,
456
+ ): RequiredFieldsModel => {
457
+ const model: RequiredFieldsModel = new RequiredFieldsModel();
458
+ // _id and createdAt are required on the base model and carry no default.
459
+ model.setColumnValue("_id", ObjectID.generate());
460
+ model.setColumnValue("createdAt", OneUptimeDate.getCurrentDate());
461
+ model.setColumnValue("requiredText", "value");
462
+ if (overrides) {
463
+ overrides(model);
464
+ }
465
+ return model;
466
+ };
467
+
468
+ const check: (model: RequiredFieldsModel) => RequiredFieldsModel = (
469
+ model: RequiredFieldsModel,
470
+ ): RequiredFieldsModel => {
471
+ return (requiredFieldsService as any).checkRequiredFields(model);
472
+ };
473
+
474
+ test("accepts a required boolean set to false (regression)", () => {
475
+ const model: RequiredFieldsModel = buildModel(
476
+ (m: RequiredFieldsModel): void => {
477
+ m.setColumnValue("requiredBoolWithDefault", false);
478
+ },
479
+ );
480
+
481
+ expect(() => {
482
+ return check(model);
483
+ }).not.toThrow();
484
+ expect(model.getColumnValue("requiredBoolWithDefault")).toBe(false);
485
+ });
486
+
487
+ test("accepts a required boolean set to true", () => {
488
+ const model: RequiredFieldsModel = buildModel(
489
+ (m: RequiredFieldsModel): void => {
490
+ m.setColumnValue("requiredBoolWithDefault", true);
491
+ },
492
+ );
493
+
494
+ expect(() => {
495
+ return check(model);
496
+ }).not.toThrow();
497
+ expect(model.getColumnValue("requiredBoolWithDefault")).toBe(true);
498
+ });
499
+
500
+ test("fills the default when a required boolean is unset", () => {
501
+ const model: RequiredFieldsModel = buildModel();
502
+
503
+ expect(() => {
504
+ return check(model);
505
+ }).not.toThrow();
506
+ expect(model.getColumnValue("requiredBoolWithDefault")).toBe(false);
507
+ });
508
+
509
+ test("throws when a required column without a default is missing", () => {
510
+ const model: RequiredFieldsModel = new RequiredFieldsModel();
511
+ model.setColumnValue("_id", ObjectID.generate());
512
+ model.setColumnValue("createdAt", OneUptimeDate.getCurrentDate());
513
+ model.setColumnValue("requiredBoolWithDefault", false);
514
+ // requiredText (required, no default) intentionally left unset.
515
+
516
+ expect(() => {
517
+ return check(model);
518
+ }).toThrow(BadDataException);
519
+ expect(() => {
520
+ return check(model);
521
+ }).toThrow("requiredText is required");
522
+ });
523
+ });
524
+
382
525
  describe("execute / executeQuery per-call options", () => {
383
526
  let exec: ReturnType<typeof jest.fn>;
384
527
  let query: ReturnType<typeof jest.fn>;
@@ -395,6 +395,214 @@ describe("StatementGenerator", () => {
395
395
  expect(statement.query).toBe("");
396
396
  expect(statement.query_params).toStrictEqual({});
397
397
  });
398
+
399
+ describe("map key-array pruning", () => {
400
+ class MapModelWithAttributeKeys extends AnalyticsBaseModel {
401
+ public constructor() {
402
+ super({
403
+ tableName: "<map-table-with-attribute-keys>",
404
+ singularName: "<singular>",
405
+ pluralName: "<plural>",
406
+ tableColumns: [
407
+ new AnalyticsTableColumn({
408
+ key: "_id",
409
+ title: "<title>",
410
+ description: "<description>",
411
+ required: true,
412
+ type: TableColumnType.ObjectID,
413
+ }),
414
+ new AnalyticsTableColumn({
415
+ key: "attributes",
416
+ title: "<title>",
417
+ description: "<description>",
418
+ required: true,
419
+ defaultValue: {},
420
+ type: TableColumnType.MapStringString,
421
+ mapKeysColumn: "attributeKeys",
422
+ }),
423
+ new AnalyticsTableColumn({
424
+ key: "attributeKeys",
425
+ title: "<title>",
426
+ description: "<description>",
427
+ required: true,
428
+ defaultValue: [],
429
+ type: TableColumnType.ArrayText,
430
+ }),
431
+ ],
432
+ crudApiPath: new Route("route"),
433
+ primaryKeys: ["_id"],
434
+ sortKeys: ["_id"],
435
+ partitionKey: "_id",
436
+ tableEngine: AnalyticsTableEngine.MergeTree,
437
+ });
438
+ }
439
+ }
440
+
441
+ class MapModelWithCustomMapKeys extends AnalyticsBaseModel {
442
+ public constructor() {
443
+ super({
444
+ tableName: "<map-table-with-custom-key-column>",
445
+ singularName: "<singular>",
446
+ pluralName: "<plural>",
447
+ tableColumns: [
448
+ new AnalyticsTableColumn({
449
+ key: "_id",
450
+ title: "<title>",
451
+ description: "<description>",
452
+ required: true,
453
+ type: TableColumnType.ObjectID,
454
+ }),
455
+ new AnalyticsTableColumn({
456
+ key: "tags",
457
+ title: "<title>",
458
+ description: "<description>",
459
+ required: true,
460
+ defaultValue: {},
461
+ type: TableColumnType.MapStringString,
462
+ mapKeysColumn: "tagKeys",
463
+ }),
464
+ new AnalyticsTableColumn({
465
+ key: "tagKeys",
466
+ title: "<title>",
467
+ description: "<description>",
468
+ required: true,
469
+ defaultValue: [],
470
+ type: TableColumnType.ArrayText,
471
+ }),
472
+ ],
473
+ crudApiPath: new Route("route"),
474
+ primaryKeys: ["_id"],
475
+ sortKeys: ["_id"],
476
+ partitionKey: "_id",
477
+ tableEngine: AnalyticsTableEngine.MergeTree,
478
+ });
479
+ }
480
+ }
481
+
482
+ let mapGeneratorWithAttributeKeys: StatementGenerator<MapModelWithAttributeKeys>;
483
+
484
+ beforeEach(() => {
485
+ mapGeneratorWithAttributeKeys =
486
+ new StatementGenerator<MapModelWithAttributeKeys>({
487
+ modelType: MapModelWithAttributeKeys,
488
+ database: ClickhouseAppInstance,
489
+ });
490
+ });
491
+
492
+ test("adds linked key-array bloom-index predicate for positive equality filters", () => {
493
+ const statement: Statement =
494
+ mapGeneratorWithAttributeKeys.toWhereStatement({
495
+ attributes: { requestId: "uuid-123" },
496
+ } as any);
497
+
498
+ expect(statement.query).toBe(
499
+ "AND (empty({p0:Identifier}) OR hasAny({p1:Identifier}, {p2:Array(String)})) AND {p3:Identifier}[{p4:String}] = {p5:String}",
500
+ );
501
+ expect(statement.query_params).toStrictEqual({
502
+ p0: "attributeKeys",
503
+ p1: "attributeKeys",
504
+ p2: ["requestId"],
505
+ p3: "attributes",
506
+ p4: "requestId",
507
+ p5: "uuid-123",
508
+ });
509
+ });
510
+
511
+ test("does not add linked key-array predicate for empty equality because missing keys also match", () => {
512
+ const statement: Statement =
513
+ mapGeneratorWithAttributeKeys.toWhereStatement({
514
+ attributes: { requestId: "" },
515
+ } as any);
516
+
517
+ expect(statement.query).toBe(
518
+ "AND {p0:Identifier}[{p1:String}] = {p2:String}",
519
+ );
520
+ expect(statement.query_params).toStrictEqual({
521
+ p0: "attributes",
522
+ p1: "requestId",
523
+ p2: "",
524
+ });
525
+ });
526
+
527
+ test("adds linked key-array bloom-index predicate for positive Includes filters", () => {
528
+ const statement: Statement =
529
+ mapGeneratorWithAttributeKeys.toWhereStatement({
530
+ attributes: {
531
+ "k8s.cluster.name": new Includes(["prod-east", "prod-west"]),
532
+ },
533
+ } as any);
534
+
535
+ expect(statement.query).toBe(
536
+ "AND (empty({p0:Identifier}) OR hasAny({p1:Identifier}, {p2:Array(String)})) AND {p3:Identifier}[{p4:String}] IN {p5:Array(String)}",
537
+ );
538
+ expect(statement.query_params).toStrictEqual({
539
+ p0: "attributeKeys",
540
+ p1: "attributeKeys",
541
+ p2: ["k8s.cluster.name"],
542
+ p3: "attributes",
543
+ p4: "k8s.cluster.name",
544
+ p5: ["prod-east", "prod-west"],
545
+ });
546
+ });
547
+
548
+ test("does not add linked key-array predicate for Includes containing empty string", () => {
549
+ const statement: Statement =
550
+ mapGeneratorWithAttributeKeys.toWhereStatement({
551
+ attributes: {
552
+ "k8s.cluster.name": new Includes(["", "prod-east"]),
553
+ },
554
+ } as any);
555
+
556
+ expect(statement.query).toBe(
557
+ "AND {p0:Identifier}[{p1:String}] IN {p2:Array(String)}",
558
+ );
559
+ expect(statement.query_params).toStrictEqual({
560
+ p0: "attributes",
561
+ p1: "k8s.cluster.name",
562
+ p2: ["", "prod-east"],
563
+ });
564
+ });
565
+
566
+ test("does not add linked key-array predicate for NotEqual because missing keys still match", () => {
567
+ const statement: Statement =
568
+ mapGeneratorWithAttributeKeys.toWhereStatement({
569
+ attributes: { requestId: new NotEqual("uuid-123") },
570
+ } as any);
571
+
572
+ expect(statement.query).toBe(
573
+ "AND {p0:Identifier}[{p1:String}] != {p2:String}",
574
+ );
575
+ expect(statement.query_params).toStrictEqual({
576
+ p0: "attributes",
577
+ p1: "requestId",
578
+ p2: "uuid-123",
579
+ });
580
+ });
581
+
582
+ test("uses mapKeysColumn metadata instead of hardcoded telemetry column names", () => {
583
+ const customMapGenerator: StatementGenerator<MapModelWithCustomMapKeys> =
584
+ new StatementGenerator<MapModelWithCustomMapKeys>({
585
+ modelType: MapModelWithCustomMapKeys,
586
+ database: ClickhouseAppInstance,
587
+ });
588
+
589
+ const statement: Statement = customMapGenerator.toWhereStatement({
590
+ tags: { region: "us-east" },
591
+ } as any);
592
+
593
+ expect(statement.query).toBe(
594
+ "AND (empty({p0:Identifier}) OR hasAny({p1:Identifier}, {p2:Array(String)})) AND {p3:Identifier}[{p4:String}] = {p5:String}",
595
+ );
596
+ expect(statement.query_params).toStrictEqual({
597
+ p0: "tagKeys",
598
+ p1: "tagKeys",
599
+ p2: ["region"],
600
+ p3: "tags",
601
+ p4: "region",
602
+ p5: "us-east",
603
+ });
604
+ });
605
+ });
398
606
  });
399
607
 
400
608
  describe("ArrayText columns", () => {