@oneuptime/common 10.0.69 → 10.0.70

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 (82) hide show
  1. package/Models/DatabaseModels/KubernetesCluster.ts +5 -0
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1776865086264-MigrationName.ts +11 -8
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1776881254913-DedupeKubernetesClustersAndAddUniqueIndex.ts +134 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/DatabaseService.ts +10 -27
  6. package/Server/Services/KubernetesResourceService.ts +33 -10
  7. package/Server/Types/Database/QueryHelper.ts +127 -0
  8. package/Server/Types/Database/QueryUtil.ts +244 -0
  9. package/Types/BaseDatabase/EndsWith.ts +41 -0
  10. package/Types/BaseDatabase/IncludesAll.ts +45 -0
  11. package/Types/BaseDatabase/IncludesNone.ts +48 -0
  12. package/Types/BaseDatabase/NotContains.ts +41 -0
  13. package/Types/BaseDatabase/StartsWith.ts +41 -0
  14. package/Types/JSON.ts +20 -0
  15. package/Types/SerializableObjectDictionary.ts +10 -0
  16. package/UI/Components/Filters/BooleanFilter.tsx +1 -0
  17. package/UI/Components/Filters/DateFilter.tsx +212 -25
  18. package/UI/Components/Filters/DropdownFilter.tsx +1 -0
  19. package/UI/Components/Filters/EntityFilter.tsx +214 -41
  20. package/UI/Components/Filters/FilterViewer.tsx +228 -146
  21. package/UI/Components/Filters/FilterViewerItem.tsx +1 -11
  22. package/UI/Components/Filters/FiltersForm.tsx +148 -97
  23. package/UI/Components/Filters/NumberFilter.tsx +219 -34
  24. package/UI/Components/Filters/OperatorSelector.tsx +91 -0
  25. package/UI/Components/Filters/TextFilter.tsx +182 -71
  26. package/UI/Components/Filters/Types/FilterOperator.ts +73 -0
  27. package/UI/Components/ModelTable/BaseModelTable.tsx +8 -0
  28. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +7 -1
  29. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  30. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776865086264-MigrationName.js +1 -1
  31. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776865086264-MigrationName.js.map +1 -1
  32. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776881254913-DedupeKubernetesClustersAndAddUniqueIndex.js +123 -0
  33. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776881254913-DedupeKubernetesClustersAndAddUniqueIndex.js.map +1 -0
  34. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  35. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  36. package/build/dist/Server/Services/DatabaseService.js +9 -6
  37. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  38. package/build/dist/Server/Services/KubernetesResourceService.js +4 -2
  39. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  40. package/build/dist/Server/Types/Database/QueryHelper.js +110 -0
  41. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  42. package/build/dist/Server/Types/Database/QueryUtil.js +180 -0
  43. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  44. package/build/dist/Types/BaseDatabase/EndsWith.js +31 -0
  45. package/build/dist/Types/BaseDatabase/EndsWith.js.map +1 -0
  46. package/build/dist/Types/BaseDatabase/IncludesAll.js +34 -0
  47. package/build/dist/Types/BaseDatabase/IncludesAll.js.map +1 -0
  48. package/build/dist/Types/BaseDatabase/IncludesNone.js +34 -0
  49. package/build/dist/Types/BaseDatabase/IncludesNone.js.map +1 -0
  50. package/build/dist/Types/BaseDatabase/NotContains.js +31 -0
  51. package/build/dist/Types/BaseDatabase/NotContains.js.map +1 -0
  52. package/build/dist/Types/BaseDatabase/StartsWith.js +31 -0
  53. package/build/dist/Types/BaseDatabase/StartsWith.js.map +1 -0
  54. package/build/dist/Types/JSON.js +5 -0
  55. package/build/dist/Types/JSON.js.map +1 -1
  56. package/build/dist/Types/SerializableObjectDictionary.js +10 -0
  57. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  58. package/build/dist/UI/Components/Filters/BooleanFilter.js +1 -1
  59. package/build/dist/UI/Components/Filters/BooleanFilter.js.map +1 -1
  60. package/build/dist/UI/Components/Filters/DateFilter.js +158 -14
  61. package/build/dist/UI/Components/Filters/DateFilter.js.map +1 -1
  62. package/build/dist/UI/Components/Filters/DropdownFilter.js +1 -1
  63. package/build/dist/UI/Components/Filters/DropdownFilter.js.map +1 -1
  64. package/build/dist/UI/Components/Filters/EntityFilter.js +174 -30
  65. package/build/dist/UI/Components/Filters/EntityFilter.js.map +1 -1
  66. package/build/dist/UI/Components/Filters/FilterViewer.js +188 -97
  67. package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
  68. package/build/dist/UI/Components/Filters/FilterViewerItem.js +1 -6
  69. package/build/dist/UI/Components/Filters/FilterViewerItem.js.map +1 -1
  70. package/build/dist/UI/Components/Filters/FiltersForm.js +46 -38
  71. package/build/dist/UI/Components/Filters/FiltersForm.js.map +1 -1
  72. package/build/dist/UI/Components/Filters/NumberFilter.js +165 -23
  73. package/build/dist/UI/Components/Filters/NumberFilter.js.map +1 -1
  74. package/build/dist/UI/Components/Filters/OperatorSelector.js +41 -0
  75. package/build/dist/UI/Components/Filters/OperatorSelector.js.map +1 -0
  76. package/build/dist/UI/Components/Filters/TextFilter.js +130 -53
  77. package/build/dist/UI/Components/Filters/TextFilter.js.map +1 -1
  78. package/build/dist/UI/Components/Filters/Types/FilterOperator.js +63 -0
  79. package/build/dist/UI/Components/Filters/Types/FilterOperator.js.map +1 -0
  80. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +7 -0
  81. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  82. package/package.json +1 -1
@@ -73,6 +73,11 @@ import {
73
73
  })
74
74
  @CrudApiEndpoint(new Route("/kubernetes-cluster"))
75
75
  @SlugifyColumn("name", "slug")
76
+ // Enforce one cluster row per (projectId, clusterIdentifier) at the DB level.
77
+ // Without this, two pods emitting OTel telemetry for a new cluster at the
78
+ // same time (e.g. when the agent is first installed or during a rolling
79
+ // update) race in findOrCreateByClusterIdentifier and create duplicate rows.
80
+ @Index(["projectId", "clusterIdentifier"], { unique: true })
76
81
  @TableMetadata({
77
82
  tableName: "KubernetesCluster",
78
83
  singularName: "Kubernetes Cluster",
@@ -1,14 +1,17 @@
1
1
  import { MigrationInterface, QueryRunner } from "typeorm";
2
2
 
3
3
  export class MigrationName1776865086264 implements MigrationInterface {
4
- name = 'MigrationName1776865086264'
4
+ public name: string = "MigrationName1776865086264";
5
5
 
6
- public async up(queryRunner: QueryRunner): Promise<void> {
7
- await queryRunner.query(`ALTER TABLE "KubernetesResource" ADD "containerCount" integer`);
8
- }
9
-
10
- public async down(queryRunner: QueryRunner): Promise<void> {
11
- await queryRunner.query(`ALTER TABLE "KubernetesResource" DROP COLUMN "containerCount"`);
12
- }
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "KubernetesResource" ADD "containerCount" integer`,
9
+ );
10
+ }
13
11
 
12
+ public async down(queryRunner: QueryRunner): Promise<void> {
13
+ await queryRunner.query(
14
+ `ALTER TABLE "KubernetesResource" DROP COLUMN "containerCount"`,
15
+ );
16
+ }
14
17
  }
@@ -0,0 +1,134 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ /*
4
+ * Before this migration, KubernetesCluster had an app-level
5
+ * @UniqueColumnBy("projectId") check and a non-unique index on
6
+ * clusterIdentifier, but no DB-level uniqueness. Under concurrent telemetry
7
+ * from multiple agent pods (happens every time the agent is installed or
8
+ * rolls out), findOrCreateByClusterIdentifier would race between its find
9
+ * and its create, and the DB accepted both inserts — producing duplicate
10
+ * rows with identical (projectId, clusterIdentifier).
11
+ *
12
+ * This migration:
13
+ * 1. Reparents all FKs that reference duplicate clusters — KubernetesResource,
14
+ * KubernetesClusterOwnerUser, KubernetesClusterOwnerTeam — onto the
15
+ * oldest surviving row in each duplicate group.
16
+ * 2. Deletes the duplicate (non-survivor) rows.
17
+ * 3. Creates a DB-level unique index on (projectId, clusterIdentifier) so
18
+ * future races are rejected by the DB — the service's existing
19
+ * catch-and-refetch in findOrCreateByClusterIdentifier then returns the
20
+ * winning row instead of producing a duplicate.
21
+ *
22
+ * The auto-generator also picked up unrelated OnCallDutyPolicyScheduleLayer
23
+ * default-value drift. That's dev-environment drift, not the bug we're fixing;
24
+ * stripped from this migration.
25
+ */
26
+ export class DedupeKubernetesClustersAndAddUniqueIndex1776881254913
27
+ implements MigrationInterface
28
+ {
29
+ public name: string = "DedupeKubernetesClustersAndAddUniqueIndex1776881254913";
30
+
31
+ public async up(queryRunner: QueryRunner): Promise<void> {
32
+ // 1: reparent KubernetesResource FKs from duplicates -> survivor.
33
+ await queryRunner.query(`
34
+ WITH survivors AS (
35
+ SELECT DISTINCT ON ("projectId", "clusterIdentifier")
36
+ _id AS survivor_id,
37
+ "projectId",
38
+ "clusterIdentifier"
39
+ FROM "KubernetesCluster"
40
+ ORDER BY "projectId", "clusterIdentifier", "createdAt" ASC, _id ASC
41
+ ),
42
+ losers AS (
43
+ SELECT kc._id AS loser_id, s.survivor_id
44
+ FROM "KubernetesCluster" kc
45
+ JOIN survivors s
46
+ ON s."projectId" = kc."projectId"
47
+ AND s."clusterIdentifier" = kc."clusterIdentifier"
48
+ WHERE kc._id <> s.survivor_id
49
+ )
50
+ UPDATE "KubernetesResource" kr
51
+ SET "kubernetesClusterId" = l.survivor_id
52
+ FROM losers l
53
+ WHERE kr."kubernetesClusterId" = l.loser_id;
54
+ `);
55
+
56
+ // 2: reparent KubernetesClusterOwnerUser FKs.
57
+ await queryRunner.query(`
58
+ WITH survivors AS (
59
+ SELECT DISTINCT ON ("projectId", "clusterIdentifier")
60
+ _id AS survivor_id,
61
+ "projectId",
62
+ "clusterIdentifier"
63
+ FROM "KubernetesCluster"
64
+ ORDER BY "projectId", "clusterIdentifier", "createdAt" ASC, _id ASC
65
+ ),
66
+ losers AS (
67
+ SELECT kc._id AS loser_id, s.survivor_id
68
+ FROM "KubernetesCluster" kc
69
+ JOIN survivors s
70
+ ON s."projectId" = kc."projectId"
71
+ AND s."clusterIdentifier" = kc."clusterIdentifier"
72
+ WHERE kc._id <> s.survivor_id
73
+ )
74
+ UPDATE "KubernetesClusterOwnerUser" o
75
+ SET "kubernetesClusterId" = l.survivor_id
76
+ FROM losers l
77
+ WHERE o."kubernetesClusterId" = l.loser_id;
78
+ `);
79
+
80
+ // 3: reparent KubernetesClusterOwnerTeam FKs.
81
+ await queryRunner.query(`
82
+ WITH survivors AS (
83
+ SELECT DISTINCT ON ("projectId", "clusterIdentifier")
84
+ _id AS survivor_id,
85
+ "projectId",
86
+ "clusterIdentifier"
87
+ FROM "KubernetesCluster"
88
+ ORDER BY "projectId", "clusterIdentifier", "createdAt" ASC, _id ASC
89
+ ),
90
+ losers AS (
91
+ SELECT kc._id AS loser_id, s.survivor_id
92
+ FROM "KubernetesCluster" kc
93
+ JOIN survivors s
94
+ ON s."projectId" = kc."projectId"
95
+ AND s."clusterIdentifier" = kc."clusterIdentifier"
96
+ WHERE kc._id <> s.survivor_id
97
+ )
98
+ UPDATE "KubernetesClusterOwnerTeam" o
99
+ SET "kubernetesClusterId" = l.survivor_id
100
+ FROM losers l
101
+ WHERE o."kubernetesClusterId" = l.loser_id;
102
+ `);
103
+
104
+ // 4: delete duplicate rows now that nothing references them.
105
+ await queryRunner.query(`
106
+ WITH survivors AS (
107
+ SELECT DISTINCT ON ("projectId", "clusterIdentifier")
108
+ _id AS survivor_id,
109
+ "projectId",
110
+ "clusterIdentifier"
111
+ FROM "KubernetesCluster"
112
+ ORDER BY "projectId", "clusterIdentifier", "createdAt" ASC, _id ASC
113
+ )
114
+ DELETE FROM "KubernetesCluster" kc
115
+ USING survivors s
116
+ WHERE s."projectId" = kc."projectId"
117
+ AND s."clusterIdentifier" = kc."clusterIdentifier"
118
+ AND kc._id <> s.survivor_id;
119
+ `);
120
+
121
+ // 5: add the DB-level composite unique index.
122
+ await queryRunner.query(
123
+ `CREATE UNIQUE INDEX "IDX_9756988b48848f4f7532a2af0d" ON "KubernetesCluster" ("projectId", "clusterIdentifier") `,
124
+ );
125
+ }
126
+
127
+ public async down(queryRunner: QueryRunner): Promise<void> {
128
+ await queryRunner.query(
129
+ `DROP INDEX "public"."IDX_9756988b48848f4f7532a2af0d"`,
130
+ );
131
+ // Duplicate rows dropped in up() are lost — a down-migration cannot
132
+ // resurrect them (and reinstating duplicates is not desirable anyway).
133
+ }
134
+ }
@@ -288,6 +288,7 @@ import { MigrationName1776544084793 } from "./1776544084793-MigrationName";
288
288
  import { MigrationName1776761171349 } from "./1776761171349-MigrationName";
289
289
  import { MigrationName1776801030808 } from "./1776801030808-MigrationName";
290
290
  import { MigrationName1776865086264 } from "./1776865086264-MigrationName";
291
+ import { DedupeKubernetesClustersAndAddUniqueIndex1776881254913 } from "./1776881254913-DedupeKubernetesClustersAndAddUniqueIndex";
291
292
  export default [
292
293
  InitialMigration,
293
294
  MigrationName1717678334852,
@@ -579,4 +580,5 @@ export default [
579
580
  MigrationName1776761171349,
580
581
  MigrationName1776801030808,
581
582
  MigrationName1776865086264,
583
+ DedupeKubernetesClustersAndAddUniqueIndex1776881254913,
582
584
  ];
@@ -69,6 +69,7 @@ import { FindWhere } from "../../Types/BaseDatabase/Query";
69
69
  import Realtime from "../Utils/Realtime";
70
70
  import ModelEventType from "../../Types/Realtime/ModelEventType";
71
71
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
72
+ import type AuditLogServiceType from "./AuditLogService";
72
73
 
73
74
  class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
74
75
  public modelType!: { new (): TBaseModel };
@@ -782,14 +783,9 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
782
783
  * which extend DatabaseService). A top-level import leaves
783
784
  * DatabaseService undefined at class-extension time for subclasses.
784
785
  */
785
- // eslint-disable-next-line @typescript-eslint/no-var-requires
786
- const auditLogService: {
787
- recordCreate: (data: {
788
- model: TBaseModel;
789
- createdItem: TBaseModel;
790
- props: DatabaseCommonInteractionProps;
791
- }) => Promise<void>;
792
- } = require("./AuditLogService").default;
786
+ const auditLogService: typeof AuditLogServiceType =
787
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
788
+ require("./AuditLogService").default;
793
789
  await auditLogService.recordCreate({
794
790
  model: this.getModel(),
795
791
  createdItem: createBy.data,
@@ -1237,15 +1233,9 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1237
1233
  }
1238
1234
 
1239
1235
  if (this.getModel().enableAuditLogOn?.delete && items.length > 0) {
1240
- // eslint-disable-next-line @typescript-eslint/no-var-requires
1241
- const auditLogService: {
1242
- recordDelete: (args: {
1243
- model: TBaseModel;
1244
- deletedItem: TBaseModel;
1245
- itemId: ObjectID;
1246
- props: DatabaseCommonInteractionProps;
1247
- }) => Promise<void>;
1248
- } = require("./AuditLogService").default;
1236
+ const auditLogService: typeof AuditLogServiceType =
1237
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
1238
+ require("./AuditLogService").default;
1249
1239
  for (const item of items) {
1250
1240
  if (item.id) {
1251
1241
  await auditLogService.recordDelete({
@@ -1667,16 +1657,9 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1667
1657
  !this.hasSameValues({ item, updatedItem }) &&
1668
1658
  item.id
1669
1659
  ) {
1670
- // eslint-disable-next-line @typescript-eslint/no-var-requires
1671
- const auditLogService: {
1672
- recordUpdate: (args: {
1673
- model: TBaseModel;
1674
- before: TBaseModel;
1675
- updatedFields: JSONObject;
1676
- itemId: ObjectID;
1677
- props: DatabaseCommonInteractionProps;
1678
- }) => Promise<void>;
1679
- } = require("./AuditLogService").default;
1660
+ const auditLogService: typeof AuditLogServiceType =
1661
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
1662
+ require("./AuditLogService").default;
1680
1663
  await auditLogService.recordUpdate({
1681
1664
  model: this.getModel(),
1682
1665
  before: item,
@@ -113,7 +113,10 @@ function buildDegradedPod(row: {
113
113
  const scanForReason: (
114
114
  list: Array<Record<string, unknown>>,
115
115
  targetState: string,
116
- ) => { reason: string; message: string } | null = (list, targetState) => {
116
+ ) => { reason: string; message: string } | null = (
117
+ list: Array<Record<string, unknown>>,
118
+ targetState: string,
119
+ ) => {
117
120
  for (const cs of list) {
118
121
  if (cs["state"] !== targetState) {
119
122
  continue;
@@ -144,8 +147,10 @@ function buildDegradedPod(row: {
144
147
  reason = hit.reason;
145
148
  message = hit.message;
146
149
  } else {
147
- // Fall back to the pod-level reason/message fields set by the scheduler
148
- // (e.g. "Unschedulable" with "0/3 nodes are available: ...").
150
+ /*
151
+ * Fall back to the pod-level reason/message fields set by the scheduler
152
+ * (e.g. "Unschedulable" with "0/3 nodes are available: ...").
153
+ */
149
154
  const topReason: unknown = status["reason"];
150
155
  const topMessage: unknown = status["message"];
151
156
  if (typeof topReason === "string") {
@@ -211,7 +216,9 @@ function buildDegradedNode(row: {
211
216
 
212
217
  const findCondition: (
213
218
  predicate: (c: Record<string, unknown>) => boolean,
214
- ) => Record<string, unknown> | null = (predicate) => {
219
+ ) => Record<string, unknown> | null = (
220
+ predicate: (c: Record<string, unknown>) => boolean,
221
+ ) => {
215
222
  for (const c of conditions) {
216
223
  if (predicate(c)) {
217
224
  return c;
@@ -591,12 +598,28 @@ export class Service extends DatabaseService<Model> {
591
598
  const containerCount: number =
592
599
  parseInt(containerRows[0]?.total || "0", 10) || 0;
593
600
 
594
- const degradedPods: Array<DegradedPod> = degradedPodRows.map((row) => {
595
- return buildDegradedPod(row);
596
- });
597
- const degradedNodes: Array<DegradedNode> = degradedNodeRows.map((row) => {
598
- return buildDegradedNode(row);
599
- });
601
+ const degradedPods: Array<DegradedPod> = degradedPodRows.map(
602
+ (row: {
603
+ name: string;
604
+ namespaceKey: string;
605
+ phase: string | null;
606
+ status: unknown;
607
+ }) => {
608
+ return buildDegradedPod(row);
609
+ },
610
+ );
611
+ const degradedNodes: Array<DegradedNode> = degradedNodeRows.map(
612
+ (row: {
613
+ name: string;
614
+ isReady: boolean | null;
615
+ hasMemoryPressure: boolean | null;
616
+ hasDiskPressure: boolean | null;
617
+ hasPidPressure: boolean | null;
618
+ status: unknown;
619
+ }) => {
620
+ return buildDegradedNode(row);
621
+ },
622
+ );
600
623
 
601
624
  return {
602
625
  countsByKind,
@@ -138,6 +138,48 @@ export default class QueryHelper {
138
138
  );
139
139
  }
140
140
 
141
+ @CaptureSpan()
142
+ public static notContains(name: string): FindWhereProperty<any> {
143
+ name = name.toLowerCase().trim();
144
+ const rid: string = Text.generateRandomText(10);
145
+ return Raw(
146
+ (alias: string) => {
147
+ return `(CAST(${alias} AS TEXT) NOT ILIKE :${rid} OR ${alias} IS NULL)`;
148
+ },
149
+ {
150
+ [rid]: `%${name}%`,
151
+ },
152
+ );
153
+ }
154
+
155
+ @CaptureSpan()
156
+ public static startsWith(name: string): FindWhereProperty<any> {
157
+ name = name.toLowerCase().trim();
158
+ const rid: string = Text.generateRandomText(10);
159
+ return Raw(
160
+ (alias: string) => {
161
+ return `(CAST(${alias} AS TEXT) ILIKE :${rid})`;
162
+ },
163
+ {
164
+ [rid]: `${name}%`,
165
+ },
166
+ );
167
+ }
168
+
169
+ @CaptureSpan()
170
+ public static endsWith(name: string): FindWhereProperty<any> {
171
+ name = name.toLowerCase().trim();
172
+ const rid: string = Text.generateRandomText(10);
173
+ return Raw(
174
+ (alias: string) => {
175
+ return `(CAST(${alias} AS TEXT) ILIKE :${rid})`;
176
+ },
177
+ {
178
+ [rid]: `%${name}`,
179
+ },
180
+ );
181
+ }
182
+
141
183
  @CaptureSpan()
142
184
  public static all(values: Array<string | ObjectID>): FindWhereProperty<any> {
143
185
  values = values.map((value: string | ObjectID) => {
@@ -168,6 +210,91 @@ export default class QueryHelper {
168
210
  return this.in(values); // any and in are the same
169
211
  }
170
212
 
213
+ /**
214
+ * Returns a filter that matches owner rows that are linked to *none* of the
215
+ * provided related entity ids through a many-to-many join table. The
216
+ * returned FindOperator is intended to be applied to the primary id column
217
+ * of the owner entity.
218
+ */
219
+ @CaptureSpan()
220
+ public static noneEntitiesInManyToMany(data: {
221
+ values: Array<string | ObjectID>;
222
+ joinTableName: string;
223
+ ownerColumnName: string;
224
+ relationColumnName: string;
225
+ }): FindWhereProperty<any> {
226
+ const values: Array<string> = data.values.map(
227
+ (value: string | ObjectID) => {
228
+ return value.toString();
229
+ },
230
+ );
231
+
232
+ if (!values || values.length === 0) {
233
+ return Raw(() => {
234
+ return `TRUE = TRUE`;
235
+ }, {});
236
+ }
237
+
238
+ const valuesRid: string = Text.generateRandomText(10);
239
+
240
+ const joinTable: string = data.joinTableName.replace(/"/g, '""');
241
+ const ownerCol: string = data.ownerColumnName.replace(/"/g, '""');
242
+ const relationCol: string = data.relationColumnName.replace(/"/g, '""');
243
+
244
+ return Raw(
245
+ (alias: string) => {
246
+ return `(${alias} NOT IN (SELECT "${joinTable}"."${ownerCol}" FROM "${joinTable}" WHERE "${joinTable}"."${relationCol}" IN (:...${valuesRid})))`;
247
+ },
248
+ {
249
+ [valuesRid]: values,
250
+ },
251
+ );
252
+ }
253
+
254
+ /**
255
+ * Returns a filter that matches owner rows that are linked to *all* of the
256
+ * provided related entity ids through a many-to-many join table. The
257
+ * returned FindOperator is intended to be applied to the primary id column
258
+ * of the owner entity.
259
+ */
260
+ @CaptureSpan()
261
+ public static allEntitiesInManyToMany(data: {
262
+ values: Array<string | ObjectID>;
263
+ joinTableName: string;
264
+ ownerColumnName: string;
265
+ relationColumnName: string;
266
+ }): FindWhereProperty<any> {
267
+ const values: Array<string> = data.values.map(
268
+ (value: string | ObjectID) => {
269
+ return value.toString();
270
+ },
271
+ );
272
+
273
+ if (!values || values.length === 0) {
274
+ return Raw(() => {
275
+ return `TRUE = FALSE`;
276
+ }, {});
277
+ }
278
+
279
+ const valuesRid: string = Text.generateRandomText(10);
280
+ const countRid: string = Text.generateRandomText(10);
281
+
282
+ // Escape identifiers so they can safely be embedded in the SQL string.
283
+ const joinTable: string = data.joinTableName.replace(/"/g, '""');
284
+ const ownerCol: string = data.ownerColumnName.replace(/"/g, '""');
285
+ const relationCol: string = data.relationColumnName.replace(/"/g, '""');
286
+
287
+ return Raw(
288
+ (alias: string) => {
289
+ return `(${alias} IN (SELECT "${joinTable}"."${ownerCol}" FROM "${joinTable}" WHERE "${joinTable}"."${relationCol}" IN (:...${valuesRid}) GROUP BY "${joinTable}"."${ownerCol}" HAVING COUNT(DISTINCT "${joinTable}"."${relationCol}") >= :${countRid}))`;
290
+ },
291
+ {
292
+ [valuesRid]: values,
293
+ [countRid]: values.length,
294
+ },
295
+ );
296
+ }
297
+
171
298
  private static in(
172
299
  values: Array<string | ObjectID | number>,
173
300
  ): FindWhereProperty<any> {