Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b82fea2
Add Constraint discriminated-union types for validation refactor
alistair3149 May 4, 2026
2c17385
Rename cardinality.max to cardinality.maxItems for clarity
alistair3149 May 4, 2026
9bc0662
Add isValueEmpty helper for per-Value-Type empty detection
alistair3149 May 4, 2026
ed96369
Add optional severity field to ValueValidationError
alistair3149 May 4, 2026
080079c
Add ConstraintInterpreter for declarative constraint evaluation
alistair3149 May 4, 2026
ad06aee
Backfill RelationType.validate regression tests
alistair3149 May 4, 2026
d810523
Add delegating validate, getConstraints, validateValue to BasePropert…
alistair3149 May 4, 2026
563afea
Add curly braces to single-line if-return guards
alistair3149 May 4, 2026
8b633dd
Migrate TextType to declarative constraints
alistair3149 May 4, 2026
290d29b
Migrate SelectType to declarative constraints
alistair3149 May 4, 2026
6aeaaa6
Migrate NumberType to declarative constraints + validateValue
alistair3149 May 4, 2026
a44381c
Migrate UrlType to declarative constraints + validateValue
alistair3149 May 4, 2026
9d04c27
Migrate RelationType to declarative constraints + validateValue
alistair3149 May 4, 2026
497ae31
Migrate DateTimeType to declarative constraints + validateValue
alistair3149 May 4, 2026
947f22d
Make BasePropertyType.getConstraints abstract
alistair3149 May 4, 2026
6902746
Reshape SubjectValidator to return SubjectValidationError list
alistair3149 May 4, 2026
53db6e9
Fix tsc strict-build errors after getConstraints became abstract
alistair3149 May 4, 2026
1164342
Address final review: export Constraint, document adapter, regression…
alistair3149 May 4, 2026
f131b7f
Drop unnecessary StringValue casts and document cardinality code
alistair3149 May 4, 2026
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
1 change: 1 addition & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"neowiki-property-type-datetime",
"neowiki-infobox-type",
"neowiki-infobox-edit-link",
"neowiki-field-label-required",
"neowiki-field-required",
"neowiki-field-unique",
"neowiki-field-min-length",
Expand Down
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@

"neowiki-infobox-type": "Type",
"neowiki-infobox-edit-link": "Edit",
"neowiki-field-label-required": "This subject needs a label.",
"neowiki-field-required": "Please provide a value.",
"neowiki-field-unique": "All values must be unique.",
"neowiki-field-min-length": "Minimum length is $1 characters.",
Expand Down
1 change: 1 addition & 0 deletions i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
"neowiki-select-placeholder": "Placeholder text shown in the select dropdown before any option is chosen.",
"neowiki-select-no-results": "Message shown in the multi-select dropdown when the typed text does not match any available option.",
"neowiki-field-invalid-datetime": "Validation error shown when the entered date and time value cannot be parsed. DateTime values must be strict ISO 8601 / xsd:dateTime strings with an explicit timezone offset or 'Z' (for example, '2025-06-15T12:00:00Z' or '2025-06-15T12:00:00+02:00'). Partial values such as year-only or date-only are rejected, and min/max bounds are inclusive.",
"neowiki-field-label-required": "Error message shown when a Subject is saved or validated with an empty label.",
"neowiki-property-type-datetime": "Label for the date and time property type in the schema editor",
"neowiki-select-unknown-option": "Placeholder shown in a select-property display when a stored option ID cannot be resolved to a label (e.g. the option was removed from the Schema).",

Expand Down
9 changes: 9 additions & 0 deletions resources/ext.neowiki/src/domain/Constraint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type Severity = 'error' | 'warning';

export type Constraint =
| { kind: 'required'; severity?: Severity }
| { kind: 'minLength'; value: number; severity?: Severity }
| { kind: 'maxLength'; value: number; severity?: Severity }
| { kind: 'uniqueItems'; severity?: Severity }
| { kind: 'cardinality'; maxItems: number; severity?: Severity }
| { kind: 'enum'; allowedValues: string[]; severity?: Severity };
92 changes: 92 additions & 0 deletions resources/ext.neowiki/src/domain/ConstraintInterpreter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Constraint } from '@/domain/Constraint';
import { isValueEmpty, type Value, ValueType } from '@/domain/Value';
import type { ValueValidationError } from '@/domain/PropertyType';

export function interpretConstraints(
constraints: Constraint[],
value: Value | undefined,
): ValueValidationError[] {
const errors: ValueValidationError[] = [];
for ( const constraint of constraints ) {
errors.push( ...evaluate( constraint, value ) );
}
return errors;
}

function evaluate( constraint: Constraint, value: Value | undefined ): ValueValidationError[] {
switch ( constraint.kind ) {
case 'required':
return isValueEmpty( value ) ? [ emit( 'required', constraint ) ] : [];

case 'minLength': {
if ( value?.type !== ValueType.String ) {
return [];
}
const out: ValueValidationError[] = [];
for ( const part of value.parts ) {
if ( part.trim().length < constraint.value ) {
out.push( emit( 'min-length', constraint, { args: [ constraint.value ], source: part } ) );
}
}
return out;
}

case 'maxLength': {
if ( value?.type !== ValueType.String ) {
return [];
}
const out: ValueValidationError[] = [];
for ( const part of value.parts ) {
if ( part.trim().length > constraint.value ) {
out.push( emit( 'max-length', constraint, { args: [ constraint.value ], source: part } ) );
}
}
return out;
}

case 'uniqueItems': {
if ( value?.type !== ValueType.String ) {
return [];
}
const parts = value.parts;
return new Set( parts ).size !== parts.length ? [ emit( 'unique', constraint ) ] : [];
}

case 'cardinality': {
if ( value?.type !== ValueType.String ) {
return [];
}
const parts = value.parts;
// The 'single-value-only' code assumes maxItems === 1 (the only value used today).
// When a maxItems > 1 use case lands, branch the error code (e.g. 'max-items' with
// args: [ maxItems ]) and add a matching i18n message.
return parts.length > constraint.maxItems ? [ emit( 'single-value-only', constraint ) ] : [];
}

case 'enum': {
if ( value?.type !== ValueType.String ) {
return [];
}
const allowed = new Set( constraint.allowedValues );
const out: ValueValidationError[] = [];
for ( const part of value.parts ) {
if ( !allowed.has( part ) ) {
out.push( emit( 'invalid-option', constraint, { args: [ part ], source: part } ) );
}
}
return out;
}
}
}

function emit(
code: string,
constraint: Constraint,
extra: Partial<Omit<ValueValidationError, 'code' | 'severity'>> = {},
): ValueValidationError {
const error: ValueValidationError = { code, ...extra };
if ( constraint.severity !== undefined ) {
error.severity = constraint.severity;
}
return error;
}
21 changes: 19 additions & 2 deletions resources/ext.neowiki/src/domain/PropertyType.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { PropertyDefinition } from '@/domain/PropertyDefinition';
import type { Value } from '@/domain/Value';
import { ValueType } from '@/domain/Value';
import type { Constraint } from '@/domain/Constraint';
import { interpretConstraints } from '@/domain/ConstraintInterpreter';

export abstract class BasePropertyType<P extends PropertyDefinition, V extends Value> {

Expand All @@ -22,8 +24,18 @@ export abstract class BasePropertyType<P extends PropertyDefinition, V extends V

public abstract getExampleValue( property: P ): V;

// TODO: do we need to allow undefined for value?
public abstract validate( value: V | undefined, property: P ): ValueValidationError[];
public validate( value: V | undefined, property: P ): ValueValidationError[] {
return [
...this.validateValue( value, property ),
...interpretConstraints( this.getConstraints( property ), value ),
];
}

public abstract getConstraints( property: P ): Constraint[];

public validateValue( _value: V | undefined, _property: P ): ValueValidationError[] {
return [];
}

}

Expand All @@ -44,6 +56,11 @@ export interface ValueValidationError {
*/
source?: unknown;

/**
* Optional severity. Absent means error (default).
*/
severity?: 'error' | 'warning';

}

export type PropertyType = BasePropertyType<PropertyDefinition, Value>;
Expand Down
41 changes: 20 additions & 21 deletions resources/ext.neowiki/src/domain/SubjectValidator.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,44 @@
import { PropertyType, PropertyTypeRegistry } from '@/domain/PropertyType';
import { PropertyTypeRegistry } from '@/domain/PropertyType';
import type { ValueValidationError } from '@/domain/PropertyType';
import { Subject } from '@/domain/Subject';
import { Schema } from '@/domain/Schema';
import { Statement } from '@/domain/Statement';

export interface SubjectValidationError {
readonly propertyName: string | null;
readonly error: ValueValidationError;
}

export class SubjectValidator {

public constructor(
private readonly propertyTypeRegistry: PropertyTypeRegistry,
) {}

public validate( subject: Subject, schema: Schema ): boolean {
public validate( subject: Subject, schema: Schema ): SubjectValidationError[] {
const errors: SubjectValidationError[] = [];

if ( subject.getLabel().trim() === '' ) {
return false;
errors.push( { propertyName: null, error: { code: 'label-required' } } );
}

for ( const statement of subject.getStatements() ) {
if ( !this.statementIsValid( statement, schema ) ) {
return false;
}
errors.push( ...this.validateStatement( statement, schema ) );
}

return true;
return errors;
}

private statementIsValid( statement: Statement, schema: Schema ): boolean {
if ( !schema.getPropertyDefinitions().has( statement.propertyName ) ) {
return true; // Statements for unknown properties are considered valid
private validateStatement( statement: Statement, schema: Schema ): SubjectValidationError[] {
const propertyDef = schema.getPropertyDefinitions().get( statement.propertyName );
if ( propertyDef === undefined ) {
return [];
}

const errors =
this.getPropertyType( statement )
.validate(
statement.value,
schema.getPropertyDefinitions().get( statement.propertyName ),
);

return errors.length === 0;
}
const propertyType = this.propertyTypeRegistry.getType( statement.propertyType );
const valueErrors = propertyType.validate( statement.value, propertyDef );

private getPropertyType( statement: Statement ): PropertyType {
return this.propertyTypeRegistry.getType( statement.propertyType );
return valueErrors.map( ( error ) => ( { propertyName: statement.propertyName.toString(), error } ) );
}

}
16 changes: 16 additions & 0 deletions resources/ext.neowiki/src/domain/Value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ export function newRelation( id: string | undefined, target: SubjectId | string
);
}

export function isValueEmpty( value: Value | undefined ): boolean {
if ( value === undefined ) {
return true;
}
switch ( value.type ) {
case ValueType.String:
return value.parts.length === 0;
case ValueType.Number:
return false;
case ValueType.Boolean:
return false;
case ValueType.Relation:
return value.relations.length === 0;
}
}

export function relationValuesHaveSameTargets(
a: RelationValue | undefined,
b: RelationValue | undefined,
Expand Down
53 changes: 22 additions & 31 deletions resources/ext.neowiki/src/domain/propertyTypes/DateTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { PropertyDefinition } from '@/domain/PropertyDefinition';
import { PropertyName } from '@/domain/PropertyDefinition';
import { newStringValue, type StringValue, ValueType } from '@/domain/Value';
import { BasePropertyType, ValueValidationError } from '@/domain/PropertyType';
import type { Constraint } from '@/domain/Constraint';

export interface DateTimeProperty extends PropertyDefinition {

Expand Down Expand Up @@ -112,41 +113,31 @@ export class DateTimeType extends BasePropertyType<DateTimeProperty, StringValue
} as DateTimeProperty;
}

public validate( value: StringValue | undefined, property: DateTimeProperty ): ValueValidationError[] {
const errors: ValueValidationError[] = [];
public getConstraints( property: DateTimeProperty ): Constraint[] {
return property.required ? [ { kind: 'required' } ] : [];
}

if ( property.required && value === undefined ) {
errors.push( { code: 'required' } );
public validateValue(
value: StringValue | undefined,
property: DateTimeProperty,
): ValueValidationError[] {
if ( value === undefined || value.parts.length === 0 ) {
return [];
}
const errors: ValueValidationError[] = [];
const timestamp = parseStrictDateTime( value.parts[ 0 ] );
if ( timestamp === null ) {
errors.push( { code: 'invalid-datetime' } );
return errors;
}

if ( value !== undefined && value.parts.length > 0 ) {
const timestamp = parseStrictDateTime( value.parts[ 0 ] );

if ( timestamp === null ) {
errors.push( { code: 'invalid-datetime' } );
return errors;
}

const minimum = property.minimum;
const minimumTimestamp = minimum !== undefined ? parseStrictDateTime( minimum ) : null;
if ( minimum !== undefined && minimumTimestamp !== null && timestamp < minimumTimestamp ) {
errors.push( {
code: 'min-value',
args: [ minimum ],
} );
}

const maximum = property.maximum;
const maximumTimestamp = maximum !== undefined ? parseStrictDateTime( maximum ) : null;
if ( maximum !== undefined && maximumTimestamp !== null && timestamp > maximumTimestamp ) {
errors.push( {
code: 'max-value',
args: [ maximum ],
} );
}
const min = property.minimum !== undefined ? parseStrictDateTime( property.minimum ) : null;
if ( min !== null && timestamp < min ) {
errors.push( { code: 'min-value', args: [ property.minimum ] } );
}
const max = property.maximum !== undefined ? parseStrictDateTime( property.maximum ) : null;
if ( max !== null && timestamp > max ) {
errors.push( { code: 'max-value', args: [ property.maximum ] } );
}

return errors;
}

Expand Down
33 changes: 13 additions & 20 deletions resources/ext.neowiki/src/domain/propertyTypes/Number.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Constraint } from '@/domain/Constraint';
import type { PropertyDefinition } from '@/domain/PropertyDefinition';
import { PropertyName } from '@/domain/PropertyDefinition';
import { newNumberValue, type NumberValue, ValueType } from '@/domain/Value';
Expand Down Expand Up @@ -34,29 +35,21 @@ export class NumberType extends BasePropertyType<NumberProperty, NumberValue> {
} as NumberProperty;
}

public validate( value: NumberValue | undefined, property: NumberProperty ): ValueValidationError[] {
const errors: ValueValidationError[] = [];
public getConstraints( property: NumberProperty ): Constraint[] {
return property.required ? [ { kind: 'required' } ] : [];
}

if ( property.required && value === undefined ) {
errors.push( { code: 'required' } );
return errors;
public validateValue( value: NumberValue | undefined, property: NumberProperty ): ValueValidationError[] {
if ( value === undefined ) {
return [];
}

if ( value !== undefined ) {
if ( property.minimum !== undefined && value.number < property.minimum ) {
errors.push( {
code: 'min-value',
args: [ property.minimum ],
} );
}
if ( property.maximum !== undefined && value.number > property.maximum ) {
errors.push( {
code: 'max-value',
args: [ property.maximum ],
} );
}
const errors: ValueValidationError[] = [];
if ( property.minimum !== undefined && value.number < property.minimum ) {
errors.push( { code: 'min-value', args: [ property.minimum ] } );
}
if ( property.maximum !== undefined && value.number > property.maximum ) {
errors.push( { code: 'max-value', args: [ property.maximum ] } );
}

return errors;
}

Expand Down
Loading
Loading