Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 115 additions & 3 deletions common/src/hedera-modules/vcjs/vcjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,9 @@ export class VCJS {

const validate = await ajv.compileAsync(schema);
const valid = validate(vcObject);
const errors = this.enhanceConditionErrors(validate.errors as any[], schema);

return new SchemaValidationResult(valid, 'JSON_SCHEMA_VALIDATION_ERROR', validate.errors as any);
return new SchemaValidationResult(valid, 'JSON_SCHEMA_VALIDATION_ERROR', errors as any);
}

/**
Expand Down Expand Up @@ -354,6 +355,8 @@ export class VCJS {
* @param schema Schema
*/
private prepareSchema(schema: any) {
this.stripIfOnly(schema);

const defsObj = schema.$defs;
if (!defsObj) {
return;
Expand All @@ -362,12 +365,121 @@ export class VCJS {
const defsKeys = Object.keys(defsObj);
for (const key of defsKeys) {
const nestedSchema = defsObj[key];
this.stripIfOnly(nestedSchema);
const required = nestedSchema.required;
if (!required || required.length === 0) {
continue;
}
nestedSchema.required = required.filter((field: any) => !nestedSchema.properties[field] || !nestedSchema.properties[field].readOnly);
}

if (!Array.isArray(schema.allOf)) {
return;
}
const rootProperties = schema.properties || {};
const conditionalByRef = new Map<string, Set<string>>();
for (const condEntry of schema.allOf) {
if (!condEntry?.if) { continue; }
for (const branch of [condEntry.then, condEntry.else]) {
if (!branch?.properties) { continue; }
for (const propKey of Object.keys(branch.properties)) {
const constraint = branch.properties[propKey];
if (!constraint || typeof constraint !== 'object') { continue; }
const ref = rootProperties[propKey]?.$ref;
if (!ref || !defsObj[ref]) { continue; }
if (!conditionalByRef.has(ref)) { conditionalByRef.set(ref, new Set()); }
const conditionalFields = conditionalByRef.get(ref)!;
if (Array.isArray(constraint.required)) {
for (const fieldName of constraint.required) { conditionalFields.add(fieldName); }
}
if (constraint.properties) {
for (const [fieldName, val] of Object.entries(constraint.properties)) {
if (val === false) { conditionalFields.add(fieldName); }
}
}
}
}
}
for (const [ref, conditionalFields] of conditionalByRef) {
const defsEntry = defsObj[ref];
if (Array.isArray(defsEntry?.required) && conditionalFields.size) {
defsEntry.required = defsEntry.required.filter((r: string) => !conditionalFields.has(r));
}
}
}

private stripIfOnly(schema: any) {
if (!Array.isArray(schema?.allOf)) {
return;
}
schema.allOf = schema.allOf.filter(
(entry: any) => !entry?.if || entry.then !== undefined || entry.else !== undefined
);
if (schema.allOf.length === 0) {
delete schema.allOf;
}
}

/**
* Converts the nested JSON Schema `if` node into a readable condition string
*/
private describeIfCondition(node: any): string {
if (!node) { return ''; }
if (Array.isArray(node.anyOf)) {
return node.anyOf.map((b: any) => this.describeIfCondition(b)).filter(Boolean).join(' OR ');
}
if (Array.isArray(node.allOf)) {
return node.allOf.map((b: any) => this.describeIfCondition(b)).filter(Boolean).join(' AND ');
}
if (node.properties) {
return Object.entries(node.properties as Record<string, any>)
.map(([key, val]) => this.describeIfConditionLeaf(val, key))
.filter(Boolean)
.join(', ');
}
return '';
}

private describeIfConditionLeaf(node: any, leafKey: string): string {
if (!node) { return ''; }
if (node.const !== undefined) {
return `${leafKey} = '${node.const}'`;
}
if (node.properties) {
return Object.entries(node.properties as Record<string, any>)
.map(([key, val]) => this.describeIfConditionLeaf(val, key))
.filter(Boolean)
.join(', ');
}
if (Array.isArray(node.anyOf)) {
return node.anyOf.map((b: any) => this.describeIfConditionLeaf(b, leafKey)).filter(Boolean).join(' OR ');
}
if (Array.isArray(node.allOf)) {
return node.allOf.map((b: any) => this.describeIfConditionLeaf(b, leafKey)).filter(Boolean).join(' AND ');
}
return '';
}

/**
* Replaces AJV messages with a human-readable description
*/
private enhanceConditionErrors(errors: any[] | null | undefined, schema: any): any[] | null | undefined {
if (!errors?.length || !Array.isArray(schema?.allOf)) {
return errors;
}
return errors.map(error => {
if (error.keyword !== 'false schema') { return error; }
const match = (error.schemaPath as string)?.match(/^#\/allOf\/(\d+)\/(then|else)\//);
if (!match) { return error; }
const condEntry = schema.allOf[parseInt(match[1], 10)];
if (!condEntry?.if) { return error; }
const fieldName = (error.instancePath as string).split('/').filter(Boolean).pop() || 'field';
const condition = this.describeIfCondition(condEntry.if) || 'condition not met';
return {
...error,
message: `Field '${fieldName}' is not allowed unless: ${condition}`,
};
});
}

/**
Expand Down Expand Up @@ -396,10 +508,10 @@ export class VCJS {
this.prepareSchema(schema);

const validate = await ajv.compileAsync(schema);

const valid = validate(subject);
const errors = this.enhanceConditionErrors(validate.errors as any[], schema);

return new SchemaValidationResult(valid, 'JSON_SCHEMA_VALIDATION_ERROR', validate.errors as any);
return new SchemaValidationResult(valid, 'JSON_SCHEMA_VALIDATION_ERROR', errors as any);
}

/**
Expand Down
69 changes: 42 additions & 27 deletions common/src/xlsx/json-to-xlsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,9 @@ export class JsonToXlsx {
schemaCache,
enumsCache,
row,
subSchemaNames
subSchemaNames,
fieldCache,
[field.name]
);
}

Expand Down Expand Up @@ -446,32 +448,20 @@ export class JsonToXlsx {
fieldCache: Map<string, IRowField>,
) {
const baseFormula = JsonToXlsx.buildIfFormula(condition.ifCondition, fieldCache);

const thenFormula = baseFormula;
const elseFormula = `NOT(${baseFormula})`;

if (Array.isArray(condition.thenFields)) {
for (const field of condition.thenFields) {
const thenField = fieldCache.get(field.name);
if (!thenField) {
continue;
}
worksheet
.getCell(table.getCol(Dictionary.VISIBILITY), thenField.row)
.setFormulae(thenFormula);
const writeCell = (key: string, formula: string) => {
const rowField = fieldCache.get(key);
if (rowField) {
worksheet.getCell(table.getCol(Dictionary.VISIBILITY), rowField.row).setFormulae(formula);
}
}
if (Array.isArray(condition.elseFields)) {
for (const field of condition.elseFields) {
const elseField = fieldCache.get(field.name);
if (!elseField) {
continue;
}
worksheet
.getCell(table.getCol(Dictionary.VISIBILITY), elseField.row)
.setFormulae(elseFormula);
}
}
};

for (const field of condition.thenFields || []) { writeCell(field.name, thenFormula); }
for (const field of condition.elseFields || []) { writeCell(field.name, elseFormula); }
for (const t of condition.thenTargets || []) { writeCell(t.fieldPath.join('.'), thenFormula); }
for (const t of condition.elseTargets || []) { writeCell(t.fieldPath.join('.'), elseFormula); }
}

public static writeSubFields(
Expand All @@ -481,7 +471,9 @@ export class JsonToXlsx {
schemaCache: Map<string, string>,
enumsCache: Map<string, XlsxEnum>,
row: number,
subSchemaNames: Map<string, string> = new Map()
subSchemaNames: Map<string, string> = new Map(),
rootFieldCache?: Map<string, IRowField>,
pathPrefix?: string[]
): number {
if (!parent || !parent.isRef || !Array.isArray(parent.fields) || parent.fields.length === 0) {
return row;
Expand Down Expand Up @@ -512,6 +504,13 @@ export class JsonToXlsx {
subSchemaNames,
parent
);
if (rootFieldCache && pathPrefix) {
const dotPath = [...pathPrefix, field.name].join('.');
const rowField = fieldCache.get(field.name);
if (rowField) {
rootFieldCache.set(dotPath, rowField);
}
}
worksheet
.getRow(row)
.setOutline(lvl);
Expand All @@ -522,16 +521,29 @@ export class JsonToXlsx {
schemaCache,
enumsCache,
row,
subSchemaNames
subSchemaNames,
rootFieldCache,
pathPrefix ? [...pathPrefix, field.name] : undefined
);
}

// Extend local cache with relative dot-paths from rootFieldCache so nested fieldPath refs resolve.
let condCache = fieldCache;
if (rootFieldCache && pathPrefix) {
const prefix = pathPrefix.join('.') + '.';
condCache = new Map(fieldCache);
for (const [k, v] of rootFieldCache) {
if (k.startsWith(prefix)) {
condCache.set(k.slice(prefix.length), v);
}
}
}
for (const condition of parent.conditions) {
JsonToXlsx.writeCondition(
worksheet,
table,
condition,
fieldCache
condCache
);
}

Expand Down Expand Up @@ -620,7 +632,10 @@ export class JsonToXlsx {
fieldCache: Map<string, IRowField>
): string {
const toExact = (sub: any): string => {
const f = fieldCache.get(sub.field.name);
const key = (sub.fieldPath?.length > 1)
? (sub.fieldPath as string[]).join('.')
: sub.field.name;
const f = fieldCache.get(key) ?? fieldCache.get(sub.field.name);
if (!f) {
throw new Error(`Condition refers to unknown field "${sub.field?.name}".`);
}
Expand Down
53 changes: 38 additions & 15 deletions common/src/xlsx/models/schema-condition.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,48 @@
import { SchemaCondition, SchemaField } from '@guardian/interfaces';

type SingleIf = { field: SchemaField; fieldValue: any };
type SingleIf = { field: SchemaField; fieldValue: any; fieldPath?: string[] };
type GroupIf = { OR: SingleIf[] } | { AND: SingleIf[] };

function normalizePath(fieldPath?: string[]): string[] | undefined {
return Array.isArray(fieldPath) && fieldPath.length > 1 ? fieldPath : undefined;
}

function sameValue(a: any, b: any): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}

export class XlsxSchemaConditions {
private readonly single?: { field: SchemaField; value: any };
private readonly group?: { op: 'OR' | 'AND'; items: { field: SchemaField; value: any }[] };
private readonly single?: { field: SchemaField; value: any; fieldPath?: string[] };
private readonly group?: { op: 'OR' | 'AND'; items: { field: SchemaField; value: any; fieldPath?: string[] }[] };

public readonly condition: SchemaCondition;

constructor(field: SchemaField, value: any);
constructor(group: { op: 'OR' | 'AND'; items: { field: SchemaField; value: any }[] });
constructor(arg1: any, value?: any) {
constructor(field: SchemaField, value: any, fieldPath?: string[]);
constructor(group: { op: 'OR' | 'AND'; items: { field: SchemaField; value: any; fieldPath?: string[] }[] });
constructor(arg1: any, value?: any, fieldPath?: string[]) {
if (typeof arg1 === 'object' && value === undefined && arg1?.op && Array.isArray(arg1.items)) {
this.group = { op: arg1.op, items: arg1.items };
const toPredicate = (i: any) => ({
field: i.field,
fieldValue: i.value,
fieldPath: normalizePath(i.fieldPath)
});
const payload: GroupIf =
arg1.op === 'OR'
? { OR: arg1.items.map((i: any) => ({ field: i.field, fieldValue: i.value })) }
: { AND: arg1.items.map((i: any) => ({ field: i.field, fieldValue: i.value })) };
? { OR: arg1.items.map(toPredicate) }
: { AND: arg1.items.map(toPredicate) };
this.condition = {
ifCondition: payload as any,
thenFields: [],
elseFields: []
};
} else {
this.single = { field: arg1 as SchemaField, value };
this.single = { field: arg1 as SchemaField, value, fieldPath: normalizePath(fieldPath) };
this.condition = {
ifCondition: {
field: arg1 as SchemaField,
fieldValue: value
fieldValue: value,
fieldPath: normalizePath(fieldPath)
} as any,
thenFields: [],
elseFields: []
Expand All @@ -44,7 +54,7 @@ export class XlsxSchemaConditions {
return this.condition;
}

public equal(otherFieldOrGroup: any, otherValue?: any): boolean {
public equal(otherFieldOrGroup: any, otherValue?: any, otherFieldPath?: string[]): boolean {
if (this.group) {
if (!(otherFieldOrGroup?.op && Array.isArray(otherFieldOrGroup.items))) {
return false
Expand All @@ -55,16 +65,18 @@ export class XlsxSchemaConditions {
if (this.group.items.length !== otherFieldOrGroup.items.length) {
return false
};
const norm = (arr: any[]) =>
const toKey = (arr: any[]) =>
arr
.map(i => `${i.field?.name}::${JSON.stringify(i.value)}`)
.map(i => `${i.field?.name}::${JSON.stringify(i.value)}::${JSON.stringify(normalizePath(i.fieldPath))}`)
.sort()
.join('|');

return norm(this.group.items) === norm(otherFieldOrGroup.items);
return toKey(this.group.items) === toKey(otherFieldOrGroup.items);
} else {
const of = otherFieldOrGroup as SchemaField;
return of?.name === this.single.field.name && sameValue(otherValue, this.single.value);
const pathA = JSON.stringify(normalizePath(this.single.fieldPath));
const pathB = JSON.stringify(normalizePath(otherFieldPath));
return of?.name === this.single.field.name && sameValue(otherValue, this.single.value) && pathA === pathB;
}
}

Expand All @@ -75,4 +87,15 @@ export class XlsxSchemaConditions {
this.condition.thenFields.push(field);
}
}

public addTarget(field: SchemaField, targetFieldPath: string[], invert: boolean) {
const target = { field, fieldPath: targetFieldPath };
if (invert) {
if (!this.condition.elseTargets) { this.condition.elseTargets = []; }
this.condition.elseTargets.push(target);
} else {
if (!this.condition.thenTargets) { this.condition.thenTargets = []; }
this.condition.thenTargets.push(target);
}
}
}
Loading
Loading