Skip to content
Draft
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
9 changes: 8 additions & 1 deletion docs/api/schema-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,16 @@ References to other Subjects.
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `relation` | string | Yes | - | The relation type name (used in Neo4j as relationship type) |
| `targetSchema` | string | Yes | - | Name of the schema that target subjects must follow |
| `targetSchema` | string or object | Yes | - | Reference to the schema that target subjects must follow. See [Schema References](subject-format.md#schema-references). |
| `multiple` | boolean | No | `false` | Allow multiple relations |

`targetSchema` uses the same reference form as a Subject's `schema`: a bare name string for a local
Schema, or an object `{ "source", "name" }` for a Schema from another Source. Schema save validation
currently accepts only the bare string form; saving the object form, and presenting a Subject that
references a Schema from another Source, are part of the deferred sourced-Schema work (see
[Schema References](subject-format.md#schema-references)). Normalization of references naming the
local Source happens on the subject `schema` field only, so a `targetSchema` is stored as given.

Example:

```json
Expand Down
31 changes: 30 additions & 1 deletion docs/api/subject-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Each subject in the `subjects` map has the following structure:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `label` | string | Yes | Human-readable label for the subject |
| `schema` | string | Yes | Name of the Schema this subject follows (page name in the Schema namespace) |
| `schema` | string or object | Yes | Reference to the Schema this subject follows. See [Schema References](#schema-references). |
| `statements` | object | No | Map of property names to statement objects. If omitted, the subject has no statements. |

## Statement Object
Expand Down Expand Up @@ -197,6 +197,35 @@ Example: `r1demo5rrrrrrr1`

See [ADR 014](../adr/014-improved-id-format.md) for details on the ID format.

## Schema References

A Subject's `schema` field (and a relation property's `targetSchema` in the
[Schema Format](schema-format.md)) is a reference to a Schema, resolved through the Schema's own
Source ([ADR 23](../adr/023-subject-sources.md)). A Schema's Source is independent of the Subject's
Source.

- **Local form**: a bare name string, the Schema's page name in the local Schema namespace
([ADR 17](../adr/017-names-as-identifiers.md)). Local references are always stored in this form.

Example: `"schema": "Company"`

- **Foreign form**: an object `{ "source": "...", "name": "..." }` referencing a Schema from another
Source.

Example: `"schema": { "source": "otherwiki", "name": "Company" }`

Schema names are page titles and may contain colons, so the source and name are kept as separate
object fields rather than concatenated into one string. On the subject `schema` field, an object
that names the local Source is equivalent to its bare form and is normalized to it, so one Schema
is never referenced under two identities; a relation property's `targetSchema` currently accepts
only the bare string form at schema save time. Source keys are compared byte-for-byte.

Graceful degradation of an unresolvable reference (a foreign, offline, or removed Schema) currently
applies to the resolution, validation, and projection paths: resolution yields null with one logged
warning and the Subject is skipped. Presenting a Subject whose schema reference is unresolvable is
not supported on the read/View path yet: the surrounding page still renders, but that Subject's own
View does not. This is part of the deferred sourced-Schema rendering work.

## REST API

### Reading Subjects
Expand Down
8 changes: 8 additions & 0 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ A Schema ([ADR 6](adr/006-schemas.md)) defines a type of Subject. Examples: Pers

Schemas have a name, description, and a list of Property Definitions

A Schema is referenced as a `(source, name)` pair resolved through its [Source](#source)
([ADR 23](adr/023-subject-sources.md)), independent of the referring Subject's Source; a bare name
means the local Source, where the name is the Schema's page title in the Schema namespace
([ADR 17](adr/017-names-as-identifiers.md)). When such a reference cannot be resolved, the
validation and projection paths degrade gracefully (null plus a logged warning, Subject skipped);
presenting a Subject whose schema reference is unresolvable is not supported on the read/View path
yet (deferred sourced-Schema rendering).

### Property Definition

They always have a Property Name and a Property Type. Depending on the Type, they might have additional information.
Expand Down
26 changes: 26 additions & 0 deletions resources/ext.neowiki/src/domain/SchemaReference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { SchemaName } from '@/domain/Schema';

/**
* A reference to a Schema from a Source other than the local wiki (ADR 23). Schema names are page
* titles and may contain colons, so the source and name are kept as separate fields rather than
* concatenated into one string.
*/
export interface ForeignSchemaReference {
readonly source: string;
readonly name: SchemaName;
}

/**
* A reference to a Schema, resolved through its own Source. A bare name string is a local reference;
* an object is a reference to a Schema from another Source. Local references stay plain strings end
* to end.
*/
export type SchemaReference = SchemaName | ForeignSchemaReference;

/**
* The Schema's name, regardless of Source. A local reference is already its name; an object
* reference (including one naming the local Source) reduces to its name.
*/
export function schemaReferenceName( reference: SchemaReference ): SchemaName {
return typeof reference === 'string' ? reference : reference.name;
}
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,6 +2,7 @@ import type { PropertyDefinition } from '@/domain/PropertyDefinition';
import { PropertyName } from '@/domain/PropertyDefinition';
import { newRelation, RelationValue, ValueType } from '@/domain/Value';
import { BasePropertyType } from '@/domain/PropertyType';
import { schemaReferenceName } from '@/domain/SchemaReference';

export interface RelationProperty extends PropertyDefinition {

Expand Down Expand Up @@ -34,7 +35,7 @@ export class RelationType extends BasePropertyType<RelationProperty, RelationVal
return {
...base,
relation: json.relation,
targetSchema: json.targetSchema,
targetSchema: schemaReferenceName( json.targetSchema ),
multiple: json.multiple ?? false,
} as RelationProperty;
}
Expand Down
3 changes: 2 additions & 1 deletion resources/ext.neowiki/src/persistence/SubjectDeserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { PageIdentifiers } from '@/domain/PageIdentifiers';
import { StatementList } from '@/domain/StatementList';
import { StatementDeserializer } from '@/persistence/StatementDeserializer';
import { SubjectWithContext } from '@/domain/SubjectWithContext';
import { schemaReferenceName } from '@/domain/SchemaReference';

export class SubjectDeserializer {

Expand All @@ -14,7 +15,7 @@ export class SubjectDeserializer {
public deserialize( json: any ): SubjectWithContext {
const id = new SubjectId( json.id );
const label = json.label;
const schema = json.schema;
const schema = schemaReferenceName( json.schema );

const pageIdentifiers = new PageIdentifiers( json.pageId, json.pageTitle );
const statementList = this.deserializeStatements( json.statements );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ describe( 'SubjectDeserializer', () => {
) );
} );

it( 'reduces a foreign schema reference to its name', () => {
const json = {
id: 's13333333333337',
label: 'SubjectDeserializer',
schema: { source: 'otherwiki', name: 'SDSchema' },
statements: {},
pageId: 42,
pageTitle: 'SDPageTitle',
};

const subject = deserializer.deserialize( json );

expect( subject ).toEqual( new SubjectWithContext(
new SubjectId( 's13333333333337' ),
'SubjectDeserializer',
'SDSchema',
new StatementList( [] ),
new PageIdentifiers( 42, 'SDPageTitle' ),
) );
} );

it( 'deserializes Subject with Statements', () => {
const json = {
id: 's13333333333337',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,18 @@ it( 'creates a relation property definition with all fields', () => {
expect( property.multiple ).toBe( true );
} );

it( 'reduces a foreign target schema reference to its name', () => {
const json = {
type: 'relation',
relation: 'Employer',
targetSchema: { source: 'otherwiki', name: 'Company' },
};

const property = serializer.propertyDefinitionFromJson( 'test', json ) as RelationProperty;

expect( property.targetSchema ).toBe( 'Company' );
} );

it( 'degrades gracefully to a placeholder definition for an unregistered type', () => {
const json = {
type: 'color',
Expand Down
22 changes: 22 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,22 @@
import { describe, expect, it } from 'vitest';
import { schemaReferenceName } from '@/domain/SchemaReference';

describe( 'schemaReferenceName', () => {

it( 'returns a local reference unchanged', () => {
expect( schemaReferenceName( 'Person' ) ).toBe( 'Person' );
} );

it( 'returns a local name that contains a colon unchanged', () => {
expect( schemaReferenceName( 'Namespace:Person' ) ).toBe( 'Namespace:Person' );
} );

it( 'reduces a foreign reference to its name', () => {
expect( schemaReferenceName( { source: 'otherwiki', name: 'Person' } ) ).toBe( 'Person' );
} );

it( 'reduces a foreign name that contains a colon to its name', () => {
expect( schemaReferenceName( { source: 'otherwiki', name: 'Namespace:Person' } ) ).toBe( 'Namespace:Person' );
} );

} );
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use InvalidArgumentException;
use ProfessionalWiki\NeoWiki\Application\PageIdentifiersLookup;
use ProfessionalWiki\NeoWiki\Application\SchemaLookup;
use ProfessionalWiki\NeoWiki\Application\SchemaReferenceResolver;
use ProfessionalWiki\NeoWiki\Application\SelectStatementResolver;
use ProfessionalWiki\NeoWiki\Application\StatementListBuilder;
use ProfessionalWiki\NeoWiki\Application\Subject\Exception\SubjectEditNotAuthorizedException;
Expand All @@ -26,7 +26,7 @@ public function __construct(
private SubjectRepository $subjectRepository,
private SubjectWriteAuthorizer $writeAuthorizer,
private StatementListBuilder $statementListBuilder,
private SchemaLookup $schemaLookup,
private SchemaReferenceResolver $schemaReferenceResolver,
private SelectStatementResolver $selectStatementResolver,
private ProposedSubjectValidator $proposedSubjectValidator,
private ReplaceSubjectPresenter $presenter,
Expand Down Expand Up @@ -88,7 +88,7 @@ public function replace( SubjectId $subjectId, string $label, array $statements,
* @return array<string, mixed>
*/
private function resolveStatements( Subject $subject, array $statements ): array {
$schema = $this->schemaLookup->getSchema( $subject->getSchemaName() );
$schema = $this->schemaReferenceResolver->resolve( $subject->getSchemaReference() );

if ( $schema === null ) {
return $statements;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace ProfessionalWiki\NeoWiki\Application\Queries\ValidateSubjectUpdate;

use ProfessionalWiki\NeoWiki\Application\SchemaLookup;
use ProfessionalWiki\NeoWiki\Application\SchemaReferenceResolver;
use ProfessionalWiki\NeoWiki\Application\SelectStatementResolver;
use ProfessionalWiki\NeoWiki\Application\StatementListBuilder;
use ProfessionalWiki\NeoWiki\Application\Subject\Exception\SubjectNotFoundException;
Expand All @@ -18,7 +18,7 @@

public function __construct(
private SubjectRepository $subjectRepository,
private SchemaLookup $schemaLookup,
private SchemaReferenceResolver $schemaReferenceResolver,
private SubjectValidator $subjectValidator,
private StatementListBuilder $statementListBuilder,
private SelectStatementResolver $selectStatementResolver,
Expand All @@ -42,7 +42,7 @@ public function validate( string $subjectId, string $label, array $statements ):
throw SubjectNotFoundException::forId( $id );
}

$schema = $this->schemaLookup->getSchema( $subject->getSchemaName() );
$schema = $this->schemaReferenceResolver->resolve( $subject->getSchemaReference() );

if ( $schema === null ) {
return [
Expand Down
6 changes: 3 additions & 3 deletions src/Application/Rdf/RdfPageProjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use DateTimeImmutable;
use DateTimeZone;
use ProfessionalWiki\NeoWiki\Application\SchemaLookup;
use ProfessionalWiki\NeoWiki\Application\SchemaReferenceResolver;
use ProfessionalWiki\NeoWiki\Domain\Page\Page;
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
use ProfessionalWiki\NeoWiki\Domain\Page\PageValue;
Expand Down Expand Up @@ -47,7 +47,7 @@ class RdfPageProjector implements PageProjector {
public function __construct(
private readonly RdfValueMapperRegistry $valueMappers,
private readonly RdfNamespaces $namespaces,
private readonly SchemaLookup $schemaLookup,
private readonly SchemaReferenceResolver $schemaReferenceResolver,
private readonly LoggerInterface $logger,
) {
}
Expand Down Expand Up @@ -80,7 +80,7 @@ private function resolveSchemas( array $subjects ): array {
$resolved = [];

foreach ( $subjects as $subject ) {
$schema = $this->schemaLookup->getSchema( $subject->getSchemaName() );
$schema = $this->schemaReferenceResolver->resolve( $subject->getSchemaReference() );

if ( $schema === null ) {
$this->logger->warning( 'Schema not found: ' . $subject->getSchemaName()->getText() );
Expand Down
19 changes: 19 additions & 0 deletions src/Application/SchemaReferenceResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Application;

use ProfessionalWiki\NeoWiki\Domain\Schema\Schema;
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaReference;

/**
* Resolves a SchemaReference to its Schema through the reference's own Source (ADR 23), or null when
* it cannot be resolved. The name-parameterized {@see SchemaLookup} stays the local, name-keyed seam;
* this one is for schema references carried by Subjects, whose source may differ from the subject's.
*/
interface SchemaReferenceResolver {

public function resolve( SchemaReference $reference ): ?Schema;

}
40 changes: 40 additions & 0 deletions src/Application/SourceRoutingSchemaReferenceResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Application;

use ProfessionalWiki\NeoWiki\Domain\Schema\Schema;
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaReference;
use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry;
use Psr\Log\LoggerInterface;

/**
* Resolves a SchemaReference through the Source its key resolves to. A reference whose source key is
* not registered degrades to not-found (null) plus one logged warning, never fatally (ADR 27),
* mirroring {@see SourceRoutingSubjectLookup}. A local reference routes through the local Source and
* behaves exactly as a direct name-keyed schema lookup.
*/
readonly class SourceRoutingSchemaReferenceResolver implements SchemaReferenceResolver {

public function __construct(
private SourceRegistry $sourceRegistry,
private LoggerInterface $logger,
) {
}

public function resolve( SchemaReference $reference ): ?Schema {
$source = $this->sourceRegistry->getSourceForSchemaReference( $reference );

if ( $source === null ) {
$this->logger->warning(
'Schema resolution for an unregistered Source key.',
[ 'sourceKey' => $reference->getSource(), 'schemaName' => $reference->getName()->getText() ]
);
return null;
}

return $source->getSchema( $reference->getName() );
}

}
6 changes: 3 additions & 3 deletions src/Application/Validation/ProposedSubjectValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace ProfessionalWiki\NeoWiki\Application\Validation;

use ProfessionalWiki\NeoWiki\Application\SchemaLookup;
use ProfessionalWiki\NeoWiki\Application\SchemaReferenceResolver;
use ProfessionalWiki\NeoWiki\Domain\Subject\Subject;
use ProfessionalWiki\NeoWiki\Domain\Validation\Violation;

Expand All @@ -24,7 +24,7 @@
readonly class ProposedSubjectValidator {

public function __construct(
private SchemaLookup $schemaLookup,
private SchemaReferenceResolver $schemaReferenceResolver,
private SubjectValidator $subjectValidator,
) {
}
Expand All @@ -33,7 +33,7 @@ public function __construct(
* @return Violation[]
*/
public function validate( Subject $subject ): array {
$schema = $this->schemaLookup->getSchema( $subject->getSchemaName() );
$schema = $this->schemaReferenceResolver->resolve( $subject->getSchemaReference() );

if ( $schema === null ) {
return [
Expand Down
Loading
Loading