Skip to content

Refactor frontend validation: declarative constraints + interpreter#807

Open
alistair3149 wants to merge 19 commits intomasterfrom
constraint-interpreter
Open

Refactor frontend validation: declarative constraints + interpreter#807
alistair3149 wants to merge 19 commits intomasterfrom
constraint-interpreter

Conversation

@alistair3149
Copy link
Copy Markdown
Member

@alistair3149 alistair3149 commented May 4, 2026

Result of extensive back-and-forth with @alistair3149.
Context: the NeoWiki codebase, docs/planning/Validation.md, docs/planning/Validation-Severity.md, and PR #631.
Written by Claude Code, Opus 4.7 (1M context)

Follows-up to #631.

Summary

  • Splits per-Property-Type validate() into declarative constraint metadata + a generic interpreter, with a small per-type validateValue hook for intrinsic format checks (URL syntax, ISO-8601 parse, SubjectId validity, DateTime min/max).
  • SubjectValidator.validate returns a structured SubjectValidationError[] instead of boolean; adds i18n message neowiki-field-label-required.
  • Forward-compat severity: optional severity field on Constraint and ValueValidationError. No Property Type emits severity yet; activation is a one-line change per type when the severity-levels feature is decided.

Details

  • New Constraint discriminated union with six kinds: required, minLength, maxLength, uniqueItems, cardinality (with maxItems: number), enum (with allowedValues: string[]). Each variant carries optional severity?: 'error' | 'warning'.
  • New interpretConstraints(constraints, value) pure function in domain/ConstraintInterpreter.ts. Switches on constraint.kind; type-mismatched constraints (e.g. minLength on a NumberValue) are skipped silently.
  • New isValueEmpty(value) helper in domain/Value.ts centralizing the per-Value-Type "empty" notion.
  • BasePropertyType.validate becomes concrete and delegates [...validateValue(value, property), ...interpretConstraints(getConstraints(property), value)]. The validateValue-first ordering preserves the existing per-type test ordering. getConstraints is abstract; validateValue defaults to [].
  • All six Property Types migrated. Text and Select are pure declarative (getConstraints only). Number, Url, Relation, DateTime each contribute a small validateValue hook (3-8 lines) for the type-intrinsic format check.
  • Constraint, ConstraintInterpreter, Severity exported from public-api.ts so external TS extensions subclassing BasePropertyType can satisfy the abstract contract.
  • Backfilled regression tests for RelationType.validate (which had none) before migrating it.
  • Added DateTime regression test pinning the unified empty-value behavior — required now fires for empty StringValue (parts.length === 0), not only undefined. This matches Text and Select; the old DateTime code only flagged undefined. Net effect is a small correctness improvement.

Out of scope

  • PHP backend validation (deferred until a concrete external need; ADR 12 covers the current frontend-only stance).
  • Vue editor unification (separate spec; this work surfaces the metadata that enables it but does not deliver editor changes).
  • Severity emission, defaults, and UI surfacing.
  • Plug-in registry for new constraint kinds.
  • Text and Url's // TODO: check property.multiple gaps — preserved verbatim; closing them is a separate follow-up.

Manual Browser Check

  1. Open a Subject with the Company schema (e.g. Professional Wiki) and click Edit.
  2. In one of the Websites fields, type not-a-url. Confirm:
    • The field renders the error state (red border, cdx-text-input--status-error class).
    • Save changes is disabled.
  3. Replace not-a-url with https://example.com. Confirm the error state clears and Save changes re-enables.
  4. Open a Subject with the City schema (e.g. Berlin) and click Edit.
  5. Clear the Country field (required). Confirm Save changes is disabled.
  6. Restore Country to Germany. Confirm Save changes re-enables.
  7. Throughout, the browser JS console should remain free of errors and warnings.

Local URLs: http://localhost:8484/index.php/Professional_Wiki and http://localhost:8484/index.php/Berlin

Test plan

  • make tsci green (build + 819 tests across 79 files + lint)
  • Manual browser check above passes locally
  • Reviewer: run make tsci after pulling
  • Reviewer: walk through the Manual Browser Check

alistair3149 and others added 19 commits May 4, 2026 13:55
The 'maxItems' name is self-documenting about what's being capped (item
count) and matches the JSON Schema convention for array-length bounds,
distinguishing it from value-bound constraints like Number's minimum/maximum.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Wikimedia ESLint config (curly rule) requires braces around all
if-statement bodies. Apply to the early-return guards in isValueEmpty
and the value-type guards in interpretConstraints.
All six Property Type subclasses now implement getConstraints. Lifting
the method from non-abstract-with-default to abstract removes the dead
fallback path and makes the contract explicit.
PropertyTypeAdapter wraps external PropertyTypeRegistration plug-ins
and overrides validate directly, so getConstraints is never invoked
through it — but the abstract method still must be implemented to
satisfy the TS contract. Returning [] is correct.

The ConstraintInterpreter test that constructed a hand-rolled
StringValue used a string literal type that didn't match the
ValueType enum; switch to the enum value with an explicit StringValue
type annotation.
… test

- Export Constraint and ConstraintInterpreter from public-api.ts so
  external TS extensions subclassing BasePropertyType can satisfy the
  abstract getConstraints contract.

- Comment PropertyTypeAdapter.getConstraints to flag that it exists only
  to satisfy the abstract contract since validate() is overridden.

- Add DateTime regression test for required + empty StringValue. The
  refactor unified empty-value handling via isValueEmpty (now matching
  Text and Select behavior); the old DateTime.validate only flagged
  required when value was undefined, missing the empty-StringValue
  case. New test pins the unified behavior.
TypeScript narrowing handles the discriminated-union refinement through
the ValueType.String guard, so the (value as StringValue) casts were
defensive overkill. Remove them.

Add a comment near the cardinality emit explaining that the
'single-value-only' code is hardcoded for maxItems === 1 (today's only
use case via Select). Constraint is now exported, so external plug-ins
could construct cardinality records with maxItems > 1; the comment
flags where to branch the error code when that lands.
@JeroenDeDauw
Copy link
Copy Markdown
Member

I looked at this a bit, not a super deep dive yet, and suspect this is net-negative

It reminds me of what I did with Validator/ParamProcessor for SMW and some other extensions like Maps. That was a mistake. The declarative system seems appealing, but the complexity/indirection cost is real, making understanding/maintenance costs notably higher than having some imperative lines.

My concern is with the declarative constraints and interpreter. The PR does a bunch of other stuff that seems nice and could be merged. List with the help of CC:

  • SubjectValidator.validate returning structured SubjectValidationError[] instead of bool
  • isValueEmpty helper
  • Optional severity field on ValueValidationError
  • RelationType regression tests
  • DateTime empty-StringValue handling fix

Beyond that we could implement backend validation fairly straightforward as one of the next steps, though ADR needs to still be created, which I will do ASAP.

@JeroenDeDauw
Copy link
Copy Markdown
Member

JeroenDeDauw commented May 4, 2026

More explicitly: my concern is that the declarative+interpreter approach makes maintenance and evolution of the system more difficult, not easier, due to the complexity and indirection insufficiently paying for itself. I am NOT sure it's net-negative but would put 60/40 odds on it.

@alistair3149
Copy link
Copy Markdown
Member Author

alistair3149 commented May 4, 2026

Result of extensive back-and-forth with @alistair3149.
Context: the NeoWiki codebase and discussion on validation patterns at this codebase scale.
Written by Claude Code, Opus 4.7 (1M context)

You're right — staying imperative with shared utilities wasn't seriously considered in our brainstorm. The indirection cost likely outweighs the payoff at this scale.

The five wins you listed (SubjectValidator returning SubjectValidationError[], isValueEmpty helper, severity field, Relation regression tests, DateTime empty-StringValue handling) are the parts that pay for themselves. Pivoting to:

  • Drop Constraint.ts, ConstraintInterpreter.ts, getConstraints() abstract method, validateValue() default
  • Restore each *Type.validate() as imperative
  • Add protected utility methods on BasePropertyType (requiredCheck, uniqueItemsCheck, lengthRangeCheck, numericRangeCheck, cardinalityCheck, enumCheck) that subclasses compose
  • Mirror the same shape in PHP when backend-validation work lands

How does this pivot direction sit with you?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants