@uipath/data-fabric-tool 1.198.0 → 1.199.0-preview.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tool.js +774 -28
- package/package.json +2 -2
- package/src/commands/choice-sets.spec.ts +107 -4
- package/src/commands/choice-sets.ts +15 -3
- package/src/commands/directory.spec.ts +544 -0
- package/src/commands/directory.ts +325 -0
- package/src/commands/entities.spec.ts +239 -2
- package/src/commands/entities.ts +47 -3
- package/src/commands/records.spec.ts +119 -3
- package/src/commands/records.ts +83 -2
- package/src/commands/roles.spec.ts +153 -0
- package/src/commands/roles.ts +88 -0
- package/src/tool.ts +4 -0
- package/src/utils/validate.spec.ts +555 -0
- package/src/utils/validate.ts +611 -0
- package/tests/access-management.e2e.test.ts +347 -0
- package/tests/choice-sets-lifecycle.e2e.test.ts +741 -0
- package/tests/entities-lifecycle.e2e.test.ts +614 -0
- package/tests/files-management.e2e.test.ts +518 -0
- package/tests/records-management.e2e.test.ts +646 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side validators for Data Fabric authoring bodies.
|
|
3
|
+
*
|
|
4
|
+
* Each helper checks one rule and returns either `null` (OK) or a
|
|
5
|
+
* `ValidationError` (from @uipath/common) the caller can hand straight to
|
|
6
|
+
* `fail()`.
|
|
7
|
+
*
|
|
8
|
+
* These catch known footguns before the request hits the server:
|
|
9
|
+
* - UI-broken field types the server accepts but the Data Fabric UI can't render.
|
|
10
|
+
* - Entity / field names that violate the server's format, reserved-name, or
|
|
11
|
+
* reserved-keyword rules (the server rejects them, but with unhelpful codes).
|
|
12
|
+
* - `isUnique` toggled in an `updateFields` payload, which the server silently
|
|
13
|
+
* ignores while still returning Success.
|
|
14
|
+
* - Choice-set value `Name` fields that hit the server's case-sensitive
|
|
15
|
+
* keyword blocklist (with the additional NumberId-shift footgun a failed
|
|
16
|
+
* value-create leaves behind).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ValidationError } from "@uipath/common";
|
|
20
|
+
|
|
21
|
+
export type { ValidationError } from "@uipath/common";
|
|
22
|
+
|
|
23
|
+
// Types the SDK enum still exposes but that render broken in the Data Fabric UI.
|
|
24
|
+
// The server accepts them and `entities create` returns Success; the column then
|
|
25
|
+
// can't be rendered, filtered, or edited in the UI.
|
|
26
|
+
export const UI_BROKEN_FIELD_TYPES = new Set([
|
|
27
|
+
"INTEGER",
|
|
28
|
+
"BIG_INTEGER",
|
|
29
|
+
"FLOAT",
|
|
30
|
+
"DOUBLE",
|
|
31
|
+
"UUID",
|
|
32
|
+
"DATETIME",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const UI_BROKEN_SUBSTITUTIONS: Record<string, string> = {
|
|
36
|
+
INTEGER: "DECIMAL with decimalPrecision: 0",
|
|
37
|
+
BIG_INTEGER: "DECIMAL with decimalPrecision: 0",
|
|
38
|
+
FLOAT: "DECIMAL with a required decimalPrecision",
|
|
39
|
+
DOUBLE: "DECIMAL with a required decimalPrecision",
|
|
40
|
+
UUID: "RELATIONSHIP (if it's a foreign key) or STRING",
|
|
41
|
+
DATETIME: "DATETIME_WITH_TZ",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// System-generated columns present on every entity — reusing these names errors.
|
|
45
|
+
const RESERVED_FIELD_NAMES = new Set([
|
|
46
|
+
"Id",
|
|
47
|
+
"CreatedBy",
|
|
48
|
+
"CreateTime",
|
|
49
|
+
"UpdatedBy",
|
|
50
|
+
"UpdateTime",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// The commonly-hit C# / VB reserved words the server rejects with
|
|
54
|
+
// `RESERVED_LANGUAGE_KEYWORDS`. Match is case-insensitive: the server treats
|
|
55
|
+
// `Class`, `class`, and `CLASS` all as the same keyword.
|
|
56
|
+
// Not exhaustive — the authoritative list lives server-side. Any keyword that
|
|
57
|
+
// slips through still hits the server error path, which the CLI surfaces
|
|
58
|
+
// verbatim.
|
|
59
|
+
const RESERVED_KEYWORDS_LOWER = new Set([
|
|
60
|
+
"case",
|
|
61
|
+
"class",
|
|
62
|
+
"if",
|
|
63
|
+
"then",
|
|
64
|
+
"else",
|
|
65
|
+
"new",
|
|
66
|
+
"object",
|
|
67
|
+
"public",
|
|
68
|
+
"return",
|
|
69
|
+
"select",
|
|
70
|
+
"internal",
|
|
71
|
+
"private",
|
|
72
|
+
"static",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{2,99}$/;
|
|
76
|
+
|
|
77
|
+
export type NameKind = "entity" | "field" | "choice set";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check an entity / field / choice-set name against the server's format,
|
|
81
|
+
* reserved-name, and reserved-keyword rules.
|
|
82
|
+
*/
|
|
83
|
+
export function validateName(
|
|
84
|
+
name: string,
|
|
85
|
+
kind: NameKind,
|
|
86
|
+
): ValidationError | null {
|
|
87
|
+
// Reserved / keyword checks fire before the format check so short reserved
|
|
88
|
+
// names like `Id`, `If`, `New` get the actionable message instead of the
|
|
89
|
+
// generic "too short" one.
|
|
90
|
+
if (kind === "field" && RESERVED_FIELD_NAMES.has(name)) {
|
|
91
|
+
return {
|
|
92
|
+
message: `Field name '${name}' is reserved`,
|
|
93
|
+
instructions: `The following field names are reserved by the platform: ${[...RESERVED_FIELD_NAMES].join(", ")}. Pick a different name.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (RESERVED_KEYWORDS_LOWER.has(name.toLowerCase())) {
|
|
98
|
+
return {
|
|
99
|
+
message: `${kind === "entity" ? "Entity" : kind === "field" ? "Field" : "Choice set"} name '${name}' is a reserved C# or VB keyword`,
|
|
100
|
+
instructions:
|
|
101
|
+
"Reserved keywords are rejected with RESERVED_LANGUAGE_KEYWORDS. Pick a domain-specific rename, for example 'Case' -> 'WorkItem', 'Class' -> 'Category', 'New' -> 'IsNew'.",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!NAME_PATTERN.test(name)) {
|
|
106
|
+
return {
|
|
107
|
+
message: `Invalid ${kind} name '${name}'`,
|
|
108
|
+
instructions:
|
|
109
|
+
"Names must start with a letter, contain only letters, digits, and underscores, and be 3-100 characters long.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Scan a list of field definitions for names that fail validation. Returns
|
|
118
|
+
* the first failure; callers should surface it and let the user fix the body
|
|
119
|
+
* before calling again.
|
|
120
|
+
*/
|
|
121
|
+
export function findInvalidFieldName(
|
|
122
|
+
fields: unknown[],
|
|
123
|
+
): ValidationError | null {
|
|
124
|
+
for (const f of fields) {
|
|
125
|
+
if (typeof f !== "object" || f === null) continue;
|
|
126
|
+
const fieldName = (f as Record<string, unknown>).fieldName;
|
|
127
|
+
if (typeof fieldName !== "string") continue;
|
|
128
|
+
const err = validateName(fieldName, "field");
|
|
129
|
+
if (err) return err;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Scan a list of field definitions for any type value that lives in
|
|
136
|
+
* `UI_BROKEN_FIELD_TYPES`. Returns the first offender's suggested
|
|
137
|
+
* substitution.
|
|
138
|
+
*/
|
|
139
|
+
export function findUiBrokenFieldType(
|
|
140
|
+
fields: unknown[],
|
|
141
|
+
): ValidationError | null {
|
|
142
|
+
for (const f of fields) {
|
|
143
|
+
if (typeof f !== "object" || f === null) continue;
|
|
144
|
+
const rec = f as Record<string, unknown>;
|
|
145
|
+
const type = rec.type;
|
|
146
|
+
if (typeof type !== "string") continue;
|
|
147
|
+
if (UI_BROKEN_FIELD_TYPES.has(type)) {
|
|
148
|
+
const fieldName =
|
|
149
|
+
typeof rec.fieldName === "string" ? rec.fieldName : "<field>";
|
|
150
|
+
const substitute = UI_BROKEN_SUBSTITUTIONS[type];
|
|
151
|
+
return {
|
|
152
|
+
message: `Field '${fieldName}' uses type '${type}' which the Data Fabric UI cannot render`,
|
|
153
|
+
instructions: `The server accepts '${type}' but the UI cannot render, filter, or edit the column. Use ${substitute} instead. UI-broken types: ${[...UI_BROKEN_FIELD_TYPES].join(", ")}.`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// `referenceFolderKey` is a per-field folder hint used by RELATIONSHIP / FILE
|
|
161
|
+
// fields to point at a target that lives in a specific folder. CHOICE_SET_*
|
|
162
|
+
// fields resolve their folder server-side from `choiceSetId`, so passing
|
|
163
|
+
// `referenceFolderKey` on a CHOICE_SET_* field is always wrong. Reject it up
|
|
164
|
+
// front instead of letting the server return a misleading cross-scope error.
|
|
165
|
+
export function findReferenceFolderKeyOnChoiceSet(
|
|
166
|
+
fields: unknown[],
|
|
167
|
+
): ValidationError | null {
|
|
168
|
+
for (const f of fields) {
|
|
169
|
+
if (typeof f !== "object" || f === null) continue;
|
|
170
|
+
const rec = f as Record<string, unknown>;
|
|
171
|
+
const type = rec.type;
|
|
172
|
+
if (
|
|
173
|
+
(type === "CHOICE_SET_SINGLE" || type === "CHOICE_SET_MULTIPLE") &&
|
|
174
|
+
rec.referenceFolderKey !== undefined
|
|
175
|
+
) {
|
|
176
|
+
const fieldName =
|
|
177
|
+
typeof rec.fieldName === "string" ? rec.fieldName : "<field>";
|
|
178
|
+
return {
|
|
179
|
+
message: `CHOICE_SET_* field '${fieldName}' must not pass 'referenceFolderKey'`,
|
|
180
|
+
instructions:
|
|
181
|
+
"The server resolves a choice set's folder from 'choiceSetId' alone. Passing 'referenceFolderKey' here is rejected by the platform with a misleading cross-scope error. Drop 'referenceFolderKey' from CHOICE_SET_SINGLE / CHOICE_SET_MULTIPLE field definitions.",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Complex field types need extra config the SDK enum alone can't teach:
|
|
189
|
+
// CHOICE_SET_* takes a choiceSetId; RELATIONSHIP takes referenceEntityId +
|
|
190
|
+
// referenceFieldId. Missing extras trip a less-informative server error;
|
|
191
|
+
// catch them up front with the field name spelled out.
|
|
192
|
+
export function findMissingComplexFieldExtras(
|
|
193
|
+
fields: unknown[],
|
|
194
|
+
): ValidationError | null {
|
|
195
|
+
for (const f of fields) {
|
|
196
|
+
if (typeof f !== "object" || f === null) continue;
|
|
197
|
+
const rec = f as Record<string, unknown>;
|
|
198
|
+
const type = rec.type;
|
|
199
|
+
if (typeof type !== "string") continue;
|
|
200
|
+
const fieldName =
|
|
201
|
+
typeof rec.fieldName === "string" ? rec.fieldName : "<field>";
|
|
202
|
+
|
|
203
|
+
if (type === "CHOICE_SET_SINGLE" || type === "CHOICE_SET_MULTIPLE") {
|
|
204
|
+
if (
|
|
205
|
+
typeof rec.choiceSetId !== "string" ||
|
|
206
|
+
rec.choiceSetId.trim() === ""
|
|
207
|
+
) {
|
|
208
|
+
return {
|
|
209
|
+
message: `Field '${fieldName}' (type '${type}') requires 'choiceSetId'`,
|
|
210
|
+
instructions:
|
|
211
|
+
"Set 'choiceSetId' to the UUID from 'uip df choice-sets list'. If the target choice set doesn't exist, create it with 'uip df choice-sets create' first.",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
} else if (type === "RELATIONSHIP") {
|
|
215
|
+
if (
|
|
216
|
+
typeof rec.referenceEntityId !== "string" ||
|
|
217
|
+
rec.referenceEntityId.trim() === ""
|
|
218
|
+
) {
|
|
219
|
+
return {
|
|
220
|
+
message: `Field '${fieldName}' (type 'RELATIONSHIP') requires 'referenceEntityId'`,
|
|
221
|
+
instructions:
|
|
222
|
+
"Set 'referenceEntityId' to the UUID of the target entity from 'uip df entities list --native-only'.",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
typeof rec.referenceFieldId !== "string" ||
|
|
227
|
+
rec.referenceFieldId.trim() === ""
|
|
228
|
+
) {
|
|
229
|
+
return {
|
|
230
|
+
message: `Field '${fieldName}' (type 'RELATIONSHIP') requires 'referenceFieldId'`,
|
|
231
|
+
instructions:
|
|
232
|
+
"Set 'referenceFieldId' to the UUID of the display field on the target entity — get it from 'uip df entities get <target-entity-id>' (Fields[].Id). This controls which target field renders in the UI; the stored value is always the target record's Id UUID.",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Per-type constraint validity: lengthLimit / maxValue / minValue /
|
|
241
|
+
// decimalPrecision each apply to specific types. Passing one to an
|
|
242
|
+
// unsupported type errors server-side with a generic "does not accept"
|
|
243
|
+
// message. Catch it up front and cite the allowed range.
|
|
244
|
+
const LENGTH_LIMIT_RANGES: Record<string, [number, number]> = {
|
|
245
|
+
STRING: [1, 4000],
|
|
246
|
+
MULTILINE_TEXT: [1, 10000],
|
|
247
|
+
MULTILINE_MAX: [1, 131072],
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
function isNumber(v: unknown): v is number {
|
|
251
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function findInvalidFieldConstraints(
|
|
255
|
+
fields: unknown[],
|
|
256
|
+
): ValidationError | null {
|
|
257
|
+
for (const f of fields) {
|
|
258
|
+
if (typeof f !== "object" || f === null) continue;
|
|
259
|
+
const rec = f as Record<string, unknown>;
|
|
260
|
+
const type = rec.type;
|
|
261
|
+
if (typeof type !== "string") continue;
|
|
262
|
+
const fieldName =
|
|
263
|
+
typeof rec.fieldName === "string" ? rec.fieldName : "<field>";
|
|
264
|
+
|
|
265
|
+
if (rec.lengthLimit !== undefined) {
|
|
266
|
+
if (!isNumber(rec.lengthLimit)) {
|
|
267
|
+
return {
|
|
268
|
+
message: `Field '${fieldName}' 'lengthLimit' must be a number`,
|
|
269
|
+
instructions:
|
|
270
|
+
"Pass a positive integer within the type's range (STRING 1-4000, MULTILINE_TEXT 1-10000, MULTILINE_MAX 1-131072 UTF-16 bytes).",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const range = LENGTH_LIMIT_RANGES[type];
|
|
274
|
+
if (range === undefined) {
|
|
275
|
+
return {
|
|
276
|
+
message: `Field '${fieldName}' of type '${type}' does not accept 'lengthLimit'`,
|
|
277
|
+
instructions:
|
|
278
|
+
"'lengthLimit' applies only to STRING, MULTILINE_TEXT, and MULTILINE_MAX. Remove it or change the field type.",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const [min, max] = range;
|
|
282
|
+
if (rec.lengthLimit < min || rec.lengthLimit > max) {
|
|
283
|
+
return {
|
|
284
|
+
message: `Field '${fieldName}' 'lengthLimit' ${rec.lengthLimit} is outside the allowed range for type '${type}'`,
|
|
285
|
+
instructions: `'lengthLimit' on '${type}' must be between ${min} and ${max}. MULTILINE_MAX 'lengthLimit' is a UTF-16 byte budget (≈2 per char).`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (rec.maxValue !== undefined || rec.minValue !== undefined) {
|
|
291
|
+
if (type !== "DECIMAL") {
|
|
292
|
+
const which =
|
|
293
|
+
rec.maxValue !== undefined ? "maxValue" : "minValue";
|
|
294
|
+
return {
|
|
295
|
+
message: `Field '${fieldName}' of type '${type}' does not accept '${which}'`,
|
|
296
|
+
instructions:
|
|
297
|
+
"'minValue' and 'maxValue' apply only to DECIMAL. Remove them or change the field type.",
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (rec.maxValue !== undefined && !isNumber(rec.maxValue)) {
|
|
301
|
+
return {
|
|
302
|
+
message: `Field '${fieldName}' 'maxValue' must be a number`,
|
|
303
|
+
instructions: "Pass a numeric value.",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (rec.minValue !== undefined && !isNumber(rec.minValue)) {
|
|
307
|
+
return {
|
|
308
|
+
message: `Field '${fieldName}' 'minValue' must be a number`,
|
|
309
|
+
instructions: "Pass a numeric value.",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (
|
|
313
|
+
rec.maxValue !== undefined &&
|
|
314
|
+
rec.minValue !== undefined &&
|
|
315
|
+
(rec.minValue as number) >= (rec.maxValue as number)
|
|
316
|
+
) {
|
|
317
|
+
return {
|
|
318
|
+
message: `Field '${fieldName}' 'minValue' (${rec.minValue}) must be strictly less than 'maxValue' (${rec.maxValue})`,
|
|
319
|
+
instructions:
|
|
320
|
+
"Adjust the bounds so 'minValue < maxValue', or drop one of the two.",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (rec.decimalPrecision !== undefined) {
|
|
326
|
+
if (type !== "DECIMAL") {
|
|
327
|
+
return {
|
|
328
|
+
message: `Field '${fieldName}' of type '${type}' does not accept 'decimalPrecision'`,
|
|
329
|
+
instructions:
|
|
330
|
+
"'decimalPrecision' applies only to DECIMAL. Remove it or change the field type.",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (
|
|
334
|
+
!isNumber(rec.decimalPrecision) ||
|
|
335
|
+
!Number.isInteger(rec.decimalPrecision) ||
|
|
336
|
+
rec.decimalPrecision < 0 ||
|
|
337
|
+
rec.decimalPrecision > 10
|
|
338
|
+
) {
|
|
339
|
+
return {
|
|
340
|
+
message: `Field '${fieldName}' 'decimalPrecision' ${rec.decimalPrecision} is invalid`,
|
|
341
|
+
instructions:
|
|
342
|
+
"'decimalPrecision' must be an integer between 0 and 10 (0 for whole numbers, 2 for money).",
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Choice-set-value Name blocklist — case-sensitive, partial list.
|
|
351
|
+
//
|
|
352
|
+
// The server's choice-set-value validator is a different code path from the
|
|
353
|
+
// entity / field-name validator, with different behavior: match is
|
|
354
|
+
// case-sensitive (`Class` may pass while `class` is rejected) and the list
|
|
355
|
+
// is narrower. Reject the lowercase tokens the server is known to reject;
|
|
356
|
+
// let the server catch anything else. The known bug where a rejected
|
|
357
|
+
// value-create shifts subsequent NumberIds is documented for the caller —
|
|
358
|
+
// this validator's job is to prevent the failed create in the first place.
|
|
359
|
+
const CHOICE_VALUE_KEYWORDS = new Set([
|
|
360
|
+
"internal",
|
|
361
|
+
"public",
|
|
362
|
+
"private",
|
|
363
|
+
"class",
|
|
364
|
+
"case",
|
|
365
|
+
"new",
|
|
366
|
+
"default",
|
|
367
|
+
"static",
|
|
368
|
+
"void",
|
|
369
|
+
"event",
|
|
370
|
+
"lock",
|
|
371
|
+
"object",
|
|
372
|
+
"string",
|
|
373
|
+
"int",
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
const CHOICE_VALUE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{2,99}$/;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Validate the `Name` argument of `choice-set-values create`. See
|
|
380
|
+
* data-fabric-tool `choice-sets.md` -> Value `Name` validation for the full
|
|
381
|
+
* story on why the rules differ from entity / field names.
|
|
382
|
+
*/
|
|
383
|
+
export function validateChoiceValueName(name: string): ValidationError | null {
|
|
384
|
+
if (!CHOICE_VALUE_NAME_PATTERN.test(name)) {
|
|
385
|
+
return {
|
|
386
|
+
message: `Invalid choice-set value name '${name}'`,
|
|
387
|
+
instructions:
|
|
388
|
+
"Choice-set value names must start with a letter, contain only letters, digits, and underscores, and be 3-100 characters long.",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (CHOICE_VALUE_KEYWORDS.has(name)) {
|
|
393
|
+
return {
|
|
394
|
+
message: `Choice-set value name '${name}' is a reserved C# keyword`,
|
|
395
|
+
instructions:
|
|
396
|
+
"The server's choice-set-value validator is case-sensitive and rejects known reserved keywords. Use lowercase snake_case with a suffix so the token no longer matches, and move the label to --display-name. Example: --display-name \"Internal\" with a name of 'internal_audit'.",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Operator tokens the server accepts on a `queryFilters[]` leaf. Anything
|
|
404
|
+
// else (`==`, `equals`, `Equals`, `like`, `BETWEEN`, `regex`, …) trips a
|
|
405
|
+
// server 400 with a message that doesn't teach the tokens; catch it here.
|
|
406
|
+
const FILTER_OPERATORS = new Set([
|
|
407
|
+
"=",
|
|
408
|
+
"!=",
|
|
409
|
+
">",
|
|
410
|
+
"<",
|
|
411
|
+
">=",
|
|
412
|
+
"<=",
|
|
413
|
+
"contains",
|
|
414
|
+
"not contains",
|
|
415
|
+
"startswith",
|
|
416
|
+
"endswith",
|
|
417
|
+
"in",
|
|
418
|
+
"not in",
|
|
419
|
+
]);
|
|
420
|
+
|
|
421
|
+
const VALUELIST_OPERATORS = new Set(["in", "not in"]);
|
|
422
|
+
|
|
423
|
+
const FILTER_OPERATORS_LIST = [...FILTER_OPERATORS]
|
|
424
|
+
.map((o) => `'${o}'`)
|
|
425
|
+
.join(", ");
|
|
426
|
+
|
|
427
|
+
function validateFilterLeaf(
|
|
428
|
+
leaf: unknown,
|
|
429
|
+
path: string,
|
|
430
|
+
): ValidationError | null {
|
|
431
|
+
if (typeof leaf !== "object" || leaf === null) {
|
|
432
|
+
return {
|
|
433
|
+
message: `${path} must be a filter object`,
|
|
434
|
+
instructions:
|
|
435
|
+
"Each queryFilters entry must be an object with fieldName, operator, and value (or valueList for 'in' / 'not in').",
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const rec = leaf as Record<string, unknown>;
|
|
439
|
+
|
|
440
|
+
if (typeof rec.fieldName !== "string" || rec.fieldName.trim() === "") {
|
|
441
|
+
return {
|
|
442
|
+
message: `${path}.fieldName must be a non-empty string`,
|
|
443
|
+
instructions:
|
|
444
|
+
'Set fieldName to the column you want to filter, for example {"fieldName":"Status","operator":"=","value":"Active"}.',
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (typeof rec.operator !== "string") {
|
|
449
|
+
return {
|
|
450
|
+
message: `${path}.operator must be a string`,
|
|
451
|
+
instructions: `Supported operators: ${FILTER_OPERATORS_LIST}.`,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
if (!FILTER_OPERATORS.has(rec.operator)) {
|
|
455
|
+
return {
|
|
456
|
+
message: `${path} uses unsupported operator '${rec.operator}'`,
|
|
457
|
+
instructions: `The server rejects operators outside the supported set. Supported: ${FILTER_OPERATORS_LIST}. Compose BETWEEN as '>=' + '<=' in one queryFilters group; use 'contains' / 'startswith' / 'endswith' in place of regex.`,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const needsValueList = VALUELIST_OPERATORS.has(rec.operator);
|
|
462
|
+
if (needsValueList) {
|
|
463
|
+
if (!Array.isArray(rec.valueList)) {
|
|
464
|
+
return {
|
|
465
|
+
message: `${path} operator '${rec.operator}' requires 'valueList' array`,
|
|
466
|
+
instructions:
|
|
467
|
+
'\'in\' and \'not in\' take a JSON array of values under \'valueList\', for example {"fieldName":"Status","operator":"in","valueList":["A","B"]}.',
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
// Every other operator uses 'value'. `null` is legal — it means
|
|
472
|
+
// is-empty (with `=`) or is-not-empty (with `!=`). Missing key is
|
|
473
|
+
// rejected so the user notices; the server accepts `undefined` but
|
|
474
|
+
// silently returns unfiltered rows.
|
|
475
|
+
if (!("value" in rec)) {
|
|
476
|
+
return {
|
|
477
|
+
message: `${path} operator '${rec.operator}' requires 'value'`,
|
|
478
|
+
instructions:
|
|
479
|
+
"Set 'value' to the JSON-string form of the compared value. Use null for is-empty ('=' with null) / is-not-empty ('!=' with null).",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function validateFilterGroup(
|
|
488
|
+
group: unknown,
|
|
489
|
+
path: string,
|
|
490
|
+
): ValidationError | null {
|
|
491
|
+
if (typeof group !== "object" || group === null || Array.isArray(group)) {
|
|
492
|
+
return {
|
|
493
|
+
message: `${path} must be an object`,
|
|
494
|
+
instructions:
|
|
495
|
+
"A filter group is {logicalOperator, queryFilters, filterGroups?}.",
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const rec = group as Record<string, unknown>;
|
|
499
|
+
|
|
500
|
+
if (rec.logicalOperator !== undefined) {
|
|
501
|
+
const op = rec.logicalOperator;
|
|
502
|
+
const okString =
|
|
503
|
+
typeof op === "string" && /^(and|or)$/i.test(op.trim());
|
|
504
|
+
const okNumber = op === 0 || op === 1;
|
|
505
|
+
if (!okString && !okNumber) {
|
|
506
|
+
return {
|
|
507
|
+
message: `${path}.logicalOperator must be 'AND' / 'OR' / 0 / 1`,
|
|
508
|
+
instructions:
|
|
509
|
+
"Use the integer form (0 for AND, 1 for OR) or the string form ('AND' / 'OR', case-insensitive).",
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (rec.queryFilters !== undefined) {
|
|
515
|
+
if (!Array.isArray(rec.queryFilters)) {
|
|
516
|
+
return {
|
|
517
|
+
message: `${path}.queryFilters must be an array`,
|
|
518
|
+
instructions:
|
|
519
|
+
"queryFilters is an array of {fieldName, operator, value|valueList} clauses.",
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
for (let i = 0; i < rec.queryFilters.length; i++) {
|
|
523
|
+
const err = validateFilterLeaf(
|
|
524
|
+
rec.queryFilters[i],
|
|
525
|
+
`${path}.queryFilters[${i}]`,
|
|
526
|
+
);
|
|
527
|
+
if (err) return err;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (rec.filterGroups !== undefined) {
|
|
532
|
+
if (!Array.isArray(rec.filterGroups)) {
|
|
533
|
+
return {
|
|
534
|
+
message: `${path}.filterGroups must be an array`,
|
|
535
|
+
instructions:
|
|
536
|
+
"filterGroups is an array of nested groups, each with the same shape as the parent.",
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
for (let i = 0; i < rec.filterGroups.length; i++) {
|
|
540
|
+
const err = validateFilterGroup(
|
|
541
|
+
rec.filterGroups[i],
|
|
542
|
+
`${path}.filterGroups[${i}]`,
|
|
543
|
+
);
|
|
544
|
+
if (err) return err;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Validate the `filterGroup` on a `records query` body. Catches unsupported
|
|
553
|
+
* operator tokens, missing value / valueList, and malformed nested groups
|
|
554
|
+
* without an SDK round-trip. Does NOT enforce the operator x field-type
|
|
555
|
+
* matrix — that needs the target entity's schema (deferred).
|
|
556
|
+
*/
|
|
557
|
+
export function validateQueryFilter(
|
|
558
|
+
filterGroup: unknown,
|
|
559
|
+
): ValidationError | null {
|
|
560
|
+
if (filterGroup === undefined) return null;
|
|
561
|
+
return validateFilterGroup(filterGroup, "filterGroup");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* `records insert` / `records update` silently strip any key whose target
|
|
566
|
+
* field is FILE-typed — the server returns Success and the file column is
|
|
567
|
+
* unchanged. Refuse the call up front so callers don't ship a body they
|
|
568
|
+
* assume was accepted. The caller provides the FILE field names (from
|
|
569
|
+
* `entities.getById`); this stays SDK-free.
|
|
570
|
+
*/
|
|
571
|
+
export function findFilePayloadKeys(
|
|
572
|
+
records: unknown[],
|
|
573
|
+
fileFieldNames: Set<string>,
|
|
574
|
+
): ValidationError | null {
|
|
575
|
+
if (fileFieldNames.size === 0) return null;
|
|
576
|
+
for (let i = 0; i < records.length; i++) {
|
|
577
|
+
const rec = records[i];
|
|
578
|
+
if (typeof rec !== "object" || rec === null) continue;
|
|
579
|
+
for (const key of Object.keys(rec as Record<string, unknown>)) {
|
|
580
|
+
if (fileFieldNames.has(key)) {
|
|
581
|
+
return {
|
|
582
|
+
message: `Record #${i} includes FILE-typed field '${key}'`,
|
|
583
|
+
instructions: `The server silently drops FILE values from records insert / update payloads and returns Success unchanged. Omit '${key}' from the body, then attach the file with 'uip df files upload <entity-id> <record-id> ${key} --file <path>'. To clear, use 'uip df files delete'.`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* `updateFields` cannot change `isUnique` — the server returns Success but
|
|
593
|
+
* silently ignores the value. Refuse the call up front so callers don't
|
|
594
|
+
* report a change that never happened.
|
|
595
|
+
*/
|
|
596
|
+
export function findIsUniqueInUpdateFields(
|
|
597
|
+
updateFields: unknown[],
|
|
598
|
+
): ValidationError | null {
|
|
599
|
+
for (const f of updateFields) {
|
|
600
|
+
if (typeof f !== "object" || f === null) continue;
|
|
601
|
+
const rec = f as Record<string, unknown>;
|
|
602
|
+
if (rec.isUnique === undefined) continue;
|
|
603
|
+
const fieldId = typeof rec.id === "string" ? rec.id : "<unknown>";
|
|
604
|
+
return {
|
|
605
|
+
message: `Cannot change 'isUnique' on field '${fieldId}' via updateFields`,
|
|
606
|
+
instructions:
|
|
607
|
+
"'isUnique' is fixed at field creation. The server silently ignores it on updateFields while still returning Success. To change uniqueness, drop the field with removeFields and re-add it with addFields (this drops every existing value in the column).",
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|