Skip to content
Merged
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
7 changes: 2 additions & 5 deletions docs/api/validation-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,6 @@ raising a Constraint's severity does not make an existing violation count as new
[ADR 21](../adr/021-add-backend-validation.md) and
[ADR 26](../adr/026-validation-severity-levels.md).

One code is exempt from the enforcement switch:
[`relation-target-unresolvable-source`](#relation-target-unresolvable-source) rejects a write either way.

## Code reference

### `required`
Expand Down Expand Up @@ -197,8 +194,8 @@ names a Source this wiki has not registered, so the target cannot be reached at
[`relation-target-not-found`](#relation-target-not-found), which is about a Source that could answer and
did not.

This one blocks the write whether or not the wiki enforces validation. Only a violation the edit
introduces blocks it, so a Subject that already carries such a target stays editable.
Only a violation the edit introduces blocks it, so a Subject that already carries such a target stays
editable.

It is also the one code that is not Schema-scoped: it is reported for every relation value on the
Subject, including a Statement the Schema does not declare and a Subject whose Schema cannot be
Expand Down
2 changes: 1 addition & 1 deletion i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@
"neowiki-severity-warning-description": "One-line meaning of the warning severity level, shown under {{msg-mw|neowiki-severity-warning}} in the severity control's menu in the schema editor. A constraint violation is the finding a Constraint yields when data does not satisfy it; marking them as warnings reports them to editors without ever rejecting a save. Parallel to {{msg-mw|neowiki-severity-error-description-not-enforced}}, which differs only in the severity named.",
"neowiki-severity-error": "Name of the error severity level of a Constraint: a violation can block saving when the wiki enforces validation. Shown in the severity control's menu in the schema editor and as $1 in {{msg-mw|neowiki-severity-input-label}}.\n{{Identical|Error}}",
"neowiki-severity-error-description-enforced": "One-line meaning of the error severity level on a wiki that enforces validation, shown under {{msg-mw|neowiki-severity-error}} in the severity control's menu in the schema editor. \"New\" is load-bearing: such a wiki rejects only writes that introduce a violation that was not already present, so Subjects that are already invalid stay editable. See also {{msg-mw|neowiki-severity-error-description-not-enforced}}.",
"neowiki-severity-error-description-not-enforced": "One-line meaning of the error severity level on a wiki that does not enforce validation, shown under {{msg-mw|neowiki-severity-error}} in the severity control's menu in the schema editor. No constraint violation blocks saving on such a wiki, so the level only decides how violations are reported to editors. Parallel to {{msg-mw|neowiki-severity-warning-description}}, which differs only in the severity named. See also {{msg-mw|neowiki-severity-error-description-enforced}}.",
"neowiki-severity-error-description-not-enforced": "One-line meaning of the error severity level on a wiki that does not enforce validation, shown under {{msg-mw|neowiki-severity-error}} in the severity control's menu in the schema editor. Nothing blocks saving on such a wiki, so the level only decides how violations are reported to editors. Parallel to {{msg-mw|neowiki-severity-warning-description}}, which differs only in the severity named. See also {{msg-mw|neowiki-severity-error-description-enforced}}.",
"neowiki-property-editor-relation": "Label for the relation-type input in the schema property editor. The relation type is the graph edge label stored for a relation property.",
"neowiki-property-editor-relation-required": "Validation error shown when the relation type is left empty.",
"neowiki-property-editor-target-schema": "Label for the target-schema picker in the schema property editor. Selects which schema a relation points to.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
{{ $i18n( 'neowiki-property-editor-target-schema' ).text() }}
</template>
<SchemaPicker
:selected="property.targetSchema || null"
:selected="localTargetSchemaName || null"
@select="updateTargetSchema"
@blur="targetSchemaTouched = true"
/>
Expand All @@ -52,6 +52,7 @@
</template>

<script setup lang="ts">
import { isLocalSchemaReference, schemaReferenceName } from '@/domain/SchemaReference';
import { computed, onMounted, ref, watch } from 'vue';
import { CdxCheckbox, CdxField, CdxTextInput } from '@wikimedia/codex';
import { RelationProperty } from '@/domain/propertyTypes/Relation.ts';
Expand Down Expand Up @@ -84,8 +85,14 @@ const relationError = computed<string | null>( () =>

const targetSchemaTouched = ref( false );

const localTargetSchemaName = computed<string>( () =>
isLocalSchemaReference( props.property.targetSchema ) ?
schemaReferenceName( props.property.targetSchema ) :
''
);

const targetSchemaError = computed<string | null>( () =>
targetSchemaTouched.value && ( props.property.targetSchema ?? '' ).trim() === '' ?
targetSchemaTouched.value && schemaReferenceName( props.property.targetSchema ).trim() === '' ?
mw.message( 'neowiki-property-editor-target-schema-required' ).text() :
null
);
Expand Down
15 changes: 13 additions & 2 deletions resources/ext.neowiki/src/components/Value/RelationInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<template #input="{ value, onUpdate, onBlur, onFocus, status, ariaLabel }">
<SubjectPicker
:selected="value"
:target-schema="props.property.targetSchema"
:target-schema="localTargetSchemaName"
:start-icon="startIcon"
:status="status"
:aria-label="ariaLabel"
Expand All @@ -37,7 +37,7 @@
<SubjectPicker
v-else
:selected="selectedId"
:target-schema="props.property.targetSchema"
:target-schema="localTargetSchemaName"
:start-icon="startIcon"
:status="fieldStatus"
@update:selected="onSingleSelectionChanged"
Expand All @@ -47,6 +47,7 @@
</template>

<script setup lang="ts">
import { isLocalSchemaReference, schemaReferenceName } from '@/domain/SchemaReference';
import { ref, watch, computed, toRef } from 'vue';
import { CdxField, CdxIcon, ValidationMessages } from '@wikimedia/codex';
import { cdxIconInfo } from '@wikimedia/codex-icons';
Expand All @@ -66,6 +67,16 @@ const props = withDefaults(
}
);

/**
* The picker searches this wiki's Subjects, so it can only be given a Schema of this wiki. A
* target Schema from another Source names nothing searchable here, and the picker is told so.
*/
const localTargetSchemaName = computed<string | null>( () =>
isLocalSchemaReference( props.property.targetSchema ) ?
schemaReferenceName( props.property.targetSchema ) :
null
);

const startIcon = NeoWikiServices.getComponentRegistry().getIcon( RelationType.typeName );

const emit = defineEmits<ValueInputEmits>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { NeoWikiServices } from '@/NeoWikiServices.ts';

interface SubjectPickerProps {
selected: string | null;
targetSchema: string;
targetSchema: string | null;
startIcon?: Icon;
status?: ValidationStatusType | 'default';
ariaLabel?: string;
Expand Down Expand Up @@ -113,6 +113,12 @@ async function onLookupInput( value: string ): Promise<void> {
searchActive.value = true;
const currentSequence = ++requestSequence;

if ( props.targetSchema === null ) {
menuItems.value = [];
searchActive.value = true;
return;
}

try {
const results = await subjectLabelSearch.searchSubjectLabels( value, props.targetSchema );

Expand Down
30 changes: 30 additions & 0 deletions resources/ext.neowiki/src/domain/SchemaReference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* How a Schema is named in the data the server sends (ADR 23). A bare string is always a Schema of
* this wiki; a Schema from another Source arrives as an object, which no local name can be mistaken
* for since a local name may itself contain a colon.
*
* The value is never rewritten here, so whatever the server sent goes back unchanged.
*
* Mirrors src/Domain/Schema/SchemaReference.php.
*/
export type SchemaReference = string | { readonly source: string; readonly name: string };

/**
* The Schema's name without its Source, for the surfaces that name a Schema of this wiki: the
* Schema picker's selection and the local Schema lookups behind it.
*/
export function schemaReferenceName( reference: SchemaReference | undefined ): string {
if ( reference === undefined ) {
return '';
}

return typeof reference === 'string' ? reference : reference.name;
}

/**
* Whether the reference names a Schema of this wiki, which is the only kind anything here can
* resolve, search against, or offer in a picker.
*/
export function isLocalSchemaReference( reference: SchemaReference | undefined ): boolean {
return typeof reference === 'string';
}
3 changes: 2 additions & 1 deletion resources/ext.neowiki/src/domain/propertyTypes/Relation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import type { PropertyDefinition } from '@/domain/PropertyDefinition';
import { PropertyName } from '@/domain/PropertyDefinition';
import { newRelation, RelationValue, ValueType } from '@/domain/Value';
import { BasePropertyType } from '@/domain/PropertyType';
import type { SchemaReference } from '@/domain/SchemaReference';

export interface RelationProperty extends PropertyDefinition {

readonly relation: string;
readonly targetSchema: string;
readonly targetSchema: SchemaReference;
readonly multiple?: boolean;

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ describe( 'RelationAttributesEditor', () => {
expect( fieldProps( wrapper, '.relation-attributes__target-schema' ).status ).toBe( 'default' );
} );

it( 'offers no local selection for a target schema from another Source', () => {
const wrapper = newWrapper( {
property: relationProperty( { targetSchema: { source: 'otherwiki', name: 'Person' } } ),
} );

expect( wrapper.findComponent( SchemaPickerStub ).props( 'selected' ) ).toBeNull();
} );

it( 'shows a required error after the empty target schema field is blurred', async () => {
const wrapper = newWrapper( {
property: relationProperty( { targetSchema: '' } ),
Expand Down
38 changes: 38 additions & 0 deletions resources/ext.neowiki/tests/domain/SchemaReference.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { isLocalSchemaReference, schemaReferenceName } from '@/domain/SchemaReference';

describe( 'schemaReferenceName', () => {

it( 'reads a bare string as the name of a Schema of this wiki', () => {
expect( schemaReferenceName( 'Product' ) ).toBe( 'Product' );
} );

it( 'keeps a colon in a local name rather than splitting it into a Source', () => {
expect( schemaReferenceName( 'ISO:9001' ) ).toBe( 'ISO:9001' );
} );

it( 'takes the name out of a Source-qualified reference', () => {
expect( schemaReferenceName( { source: 'otherwiki', name: 'Person' } ) ).toBe( 'Person' );
} );

it( 'has no name to give for an absent reference', () => {
expect( schemaReferenceName( undefined ) ).toBe( '' );
} );

} );

describe( 'isLocalSchemaReference', () => {

it( 'is true for a bare name, which always means this wiki', () => {
expect( isLocalSchemaReference( 'Product' ) ).toBe( true );
} );

it( 'is false for a reference carrying a Source', () => {
expect( isLocalSchemaReference( { source: 'otherwiki', name: 'Person' } ) ).toBe( false );
} );

it( 'is false when there is no reference at all', () => {
expect( isLocalSchemaReference( undefined ) ).toBe( false );
} );

} );
6 changes: 3 additions & 3 deletions src/Application/Actions/CreateSubject/CreateSubjectAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public function createSubject( CreateSubjectRequest $request ): void {

$violations = $this->proposedSubjectValidator->validate( $subject );

if ( $this->violationsBlockingWrite( $violations ) !== [] ) {
if ( $this->validationEnforced && $this->blockingViolations( $violations ) !== [] ) {
$this->presenter->presentValidationFailed( $violations );
return;
}
Expand Down Expand Up @@ -121,10 +121,10 @@ public function createSubject( CreateSubjectRequest $request ): void {
* @param Violation[] $violations
* @return Violation[]
*/
private function violationsBlockingWrite( array $violations ): array {
private function blockingViolations( array $violations ): array {
return array_values( array_filter(
$violations,
fn ( Violation $v ): bool => $v->alwaysBlocksWrites() || ( $this->validationEnforced && $v->isBlocking() )
static fn ( Violation $v ): bool => $v->isBlocking()
) );
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ public function replace( SubjectId $subjectId, ?string $label, array $statements
// editable rather than being frozen by it.
$newBlockingViolations = array_filter(
ViolationDiff::newViolations( $proposedViolations, $priorViolations ),
fn ( Violation $v ): bool => $v->alwaysBlocksWrites() || ( $this->validationEnforced && $v->isBlocking() )
static fn ( Violation $v ): bool => $v->isBlocking()
);

if ( $newBlockingViolations !== [] ) {
if ( $this->validationEnforced && $newBlockingViolations !== [] ) {
$this->presenter->presentValidationFailed( $proposedViolations );
return;
}
Expand Down
26 changes: 19 additions & 7 deletions src/Application/Rdf/OntologyMappingProjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public function __construct(
private readonly Mapping $mapping,
private readonly RdfNamespaces $namespaces,
private readonly RdfValueMapperRegistry $valueMappers,
private readonly SubjectIriResolver $subjectIris,
private readonly LoggerInterface $logger,
) {
$this->target = $mapping->name->getText();
Expand Down Expand Up @@ -312,10 +313,16 @@ private function perValueNodeQuads(

if ( $statement->getPropertyType() === RelationType::NAME ) {
foreach ( $this->relationsOf( $statement ) as $relation ) {
$target = $this->subjectIris->targetIri( $relation->targetId );

if ( $target === null ) {
continue;
}

$quads[] = new Quad(
$nodes->relationInstance( $node, $relation->id ),
$predicate,
$this->namespaces->subject( $relation->targetId ),
$target,
$graph
);
}
Expand Down Expand Up @@ -363,7 +370,11 @@ private function contributionQuads(
$quads = [];

foreach ( $this->relationsOf( $relationStatement ) as $relation ) {
$target = $this->namespaces->subject( $relation->targetId );
$target = $this->subjectIris->targetIri( $relation->targetId );

if ( $target === null ) {
continue;
}

foreach ( $contributed as [ $predicate, $objects ] ) {
$quads = array_merge( $quads, $this->quadsOn( $target, $predicate, $objects, $graph ) );
Expand Down Expand Up @@ -424,17 +435,18 @@ private function expandPredicate( PropertyMapping $propertyMapping, Statement $s
}

/**
* The objects a statement's values become: each relation target's native Subject IRI, or the mapped
* literal (or IRI, for a url value) each value produces.
* The objects a statement's values become: each relation target's Subject IRI, under its own
* Source's base when that is not this wiki, or the mapped literal (or IRI, for a url value) each
* value produces. A target whose Source this wiki does not have names nothing and is left out.
*
* @return list<RdfTerm>
*/
private function objectTerms( Statement $statement, PropertyMapping $propertyMapping ): array {
if ( $statement->getPropertyType() === RelationType::NAME ) {
return array_map(
fn ( Relation $relation ): Iri => $this->namespaces->subject( $relation->targetId ),
return array_values( array_filter( array_map(
fn ( Relation $relation ): ?Iri => $this->subjectIris->targetIri( $relation->targetId ),
$this->relationsOf( $statement )
);
) ) );
}

$terms = $this->valueMappers->mapValue( $statement->getPropertyType(), $statement->getValue() );
Expand Down
28 changes: 2 additions & 26 deletions src/Application/Rdf/RdfPageProjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
use ProfessionalWiki\NeoWiki\Domain\Rdf\RdfValueMapperRegistry;
use ProfessionalWiki\NeoWiki\Domain\Relation\TypedRelation;
use ProfessionalWiki\NeoWiki\Domain\Schema\Schema;
use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry;
use ProfessionalWiki\NeoWiki\Domain\Statement;
use ProfessionalWiki\NeoWiki\Domain\Subject\Subject;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectDisplayName;
Expand Down Expand Up @@ -58,7 +57,7 @@ public function __construct(
private readonly RdfValueMapperRegistry $valueMappers,
private readonly RdfNamespaces $namespaces,
private readonly SchemaResolver $schemaResolver,
private readonly SourceRegistry $sourceRegistry,
private readonly SubjectIriResolver $subjectIris,
private readonly LoggerInterface $logger,
) {
}
Expand Down Expand Up @@ -260,7 +259,7 @@ private function projectRelations( Subject $subject, Schema $schema, Iri $subjec
* @return Quad[]
*/
private function projectRelation( TypedRelation $relation, Iri $subjectIri, Iri $graph ): array {
$targetIri = $this->targetIri( $relation->targetId );
$targetIri = $this->subjectIris->targetIri( $relation->targetId );

if ( $targetIri === null ) {
return [];
Expand Down Expand Up @@ -288,29 +287,6 @@ private function projectRelation( TypedRelation $relation, Iri $subjectIri, Iri
return $quads;
}

/**
* The IRI naming the Subject a relation points at. A Subject of another Source is named under that
* Source's own base IRI, which is what makes the triple resolvable outside this wiki; a Source this
* wiki does not have leaves nothing to name, so the relation is dropped rather than minted under a
* base that is not its own.
*/
private function targetIri( SubjectId $id ): ?Iri {
if ( $id->isLocal() ) {
return $this->namespaces->subject( $id );
}

$source = $this->sourceRegistry->getSourceOf( $id );

if ( $source === null ) {
$this->logger->warning(
'Not projecting relation to ' . $id->text . ': its Source is not registered'
);
return null;
}

return new Iri( $source->getBaseUri() . $id->localId );
}

private function warnOnDroppedValues( Statement $statement, int $producedCount, PageId $pageId ): void {
$scalars = $statement->getValue()->toScalars();
$expectedCount = is_array( $scalars ) ? count( $scalars ) : 1;
Expand Down
Loading