Skip to content

Commit 33dae4b

Browse files
JeroenDeDauwclaude
andauthored
Add batch Subject-ID minting and client-supplied create IDs (#1101)
Fixes #1100 Importing many interlinked Subjects needs their IDs known before the Subjects exist, so relation statements can be wired client-side and the Subjects created in any order. Two additions cover this: - POST /neowiki/v0/subject-ids mints a batch of fresh, distinct, format-valid Subject IDs. Body `count` (1-1000). Stateless: no reservation and no graph dependency. Access model matches the validate endpoint (no write access, no CSRF, no page authorization). A small application-layer SubjectIdMinter holds the dedupe/regenerate loop; the handler enforces the count range. - The create endpoints accept an optional `id`. When present it is format-checked at the request boundary (400) and rejected when already in use (409), both against the page's own Subjects and, best-effort, the graph's subject->page index. When absent the server mints as before, and the default create path gains no new graph dependency. Also closes a duplicate-guard gap that client-supplied IDs make reachable: the page-local create guards now consider every Subject on the page (main and children), not just the sibling collection. Global Subject-ID uniqueness stays best-effort: the subject->page index lags slot writes and ID entropy carries the rest, the same posture already used for client-supplied relation IDs. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 929c375 commit 33dae4b

16 files changed

Lines changed: 685 additions & 8 deletions

File tree

docs/api/rest-api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Read, change, and validate Subjects. New Subjects are created on a page — see
5353
| `DELETE /neowiki/v0/subject/{subjectId}` | Delete a Subject. |
5454
| `POST /neowiki/v0/subject/validate` | Check whether a new Subject is valid, without saving it. |
5555
| `POST /neowiki/v0/subject/{subjectId}/validate` | Check whether a change to a Subject is valid, without saving it. |
56+
| `POST /neowiki/v0/subject-ids` | Mint a batch of unused Subject IDs to assign on create, e.g. to wire relations across an interlinked import. Body `count` (1–1000). |
5657
| `GET /neowiki/v0/subject-labels` | Find Subjects of a Schema by label; returns `id`/`label` pairs. |
5758

5859
### Pages and Subjects

docs/api/subject-format.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,23 @@ Returns the same statement format as storage, with additional fields:
200200
- Passing `?expand=relations` also embeds the Subjects targeted by this Subject's relation values; see
201201
[REST API](rest-api.md#the-expand-parameter) for the response shape.
202202

203+
### Creating Subjects
204+
205+
`POST /rest.php/neowiki/v0/page/{pageId}/mainSubject`
206+
`POST /rest.php/neowiki/v0/page/{pageId}/childSubjects`
207+
208+
Create a Subject on a page from a [Subject object](#subject-object) (`label`, `schema`, `statements`),
209+
with an optional `comment` edit summary.
210+
211+
The Subject ID is normally minted server-side. To set it yourself — for example to wire relations across
212+
a batch before the target Subjects exist — pass an optional `id`:
213+
214+
| Field | Required | Notes |
215+
|---|---|---|
216+
| `id` | No | Subject ID to assign. Must be well-formed (`400` otherwise) and unused (`409` otherwise). Omit to have the server mint one. Pre-mint a batch of IDs with `POST /neowiki/v0/subject-ids`. |
217+
218+
After creation the ID is immutable; the replace endpoint below ignores it.
219+
203220
### Writing Subjects
204221

205222
`PUT /rest.php/neowiki/v0/subject/{subjectId}`

extension.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@
289289
"method": [ "GET" ],
290290
"factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newGetSchemaSummariesApi"
291291
},
292+
{
293+
"path": "/neowiki/v0/subject-ids",
294+
"method": [ "POST" ],
295+
"factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newMintSubjectIdsApi"
296+
},
292297
{
293298
"path": "/neowiki/v0/subject/validate",
294299
"method": [ "POST" ],

src/Application/Actions/CreateSubject/CreateSubjectAction.php

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace ProfessionalWiki\NeoWiki\Application\Actions\CreateSubject;
66

77
use InvalidArgumentException;
8+
use ProfessionalWiki\NeoWiki\Application\PageIdentifiersLookup;
89
use ProfessionalWiki\NeoWiki\Application\SchemaLookup;
910
use ProfessionalWiki\NeoWiki\Application\SelectStatementResolver;
1011
use ProfessionalWiki\NeoWiki\Application\StatementListBuilder;
@@ -14,6 +15,7 @@
1415
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
1516
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName;
1617
use ProfessionalWiki\NeoWiki\Domain\Subject\Subject;
18+
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
1719
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectLabel;
1820
use ProfessionalWiki\NeoWiki\Domain\Validation\Violation;
1921
use ProfessionalWiki\NeoWiki\Infrastructure\IdGenerator;
@@ -30,6 +32,7 @@ public function __construct(
3032
private SchemaLookup $schemaLookup,
3133
private SelectStatementResolver $selectStatementResolver,
3234
private ProposedSubjectValidator $proposedSubjectValidator,
35+
private PageIdentifiersLookup $pageIdentifiersLookup,
3336
private bool $validationEnforced,
3437
) {
3538
}
@@ -47,6 +50,11 @@ public function createSubject( CreateSubjectRequest $request ): void {
4750

4851
$subject = $this->buildSubject( $request );
4952

53+
if ( $request->id !== null && $this->subjectIdIsInUse( $subject->id ) ) {
54+
$this->presenter->presentSubjectAlreadyExists();
55+
return;
56+
}
57+
5058
$pageSubjects = $this->subjectRepository->getSubjectsByPageId( $pageId );
5159

5260
try {
@@ -84,17 +92,36 @@ private function blockingViolations( array $violations ): array {
8492

8593
private function buildSubject( CreateSubjectRequest $request ): Subject {
8694
$schemaName = new SchemaName( $request->schemaName );
95+
$label = new SubjectLabel( $request->label );
96+
$statements = $this->statementListBuilder->build(
97+
$this->resolveSelectValues( $schemaName, $request->statements )
98+
);
8799

88-
return Subject::createNew(
89-
idGenerator: $this->idGenerator,
90-
label: new SubjectLabel( $request->label ),
100+
if ( $request->id === null ) {
101+
return Subject::createNew(
102+
idGenerator: $this->idGenerator,
103+
label: $label,
104+
schemaName: $schemaName,
105+
statements: $statements,
106+
);
107+
}
108+
109+
return new Subject(
110+
id: new SubjectId( $request->id ),
111+
label: $label,
91112
schemaName: $schemaName,
92-
statements: $this->statementListBuilder->build(
93-
$this->resolveSelectValues( $schemaName, $request->statements )
94-
)
113+
statements: $statements,
95114
);
96115
}
97116

117+
/**
118+
* Best-effort global uniqueness check: the subject -> page index lags slot writes, so this can
119+
* miss a very recently created Subject; ID entropy carries the rest (same posture as relation IDs).
120+
*/
121+
private function subjectIdIsInUse( SubjectId $id ): bool {
122+
return $this->pageIdentifiersLookup->getPageIdOfSubject( $id ) !== null;
123+
}
124+
98125
/**
99126
* @param array<string, mixed> $statements
100127
*

src/Application/Actions/CreateSubject/CreateSubjectRequest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ public function __construct(
2020
public array $statements,
2121

2222
public ?string $comment = null,
23+
24+
/**
25+
* Client-supplied Subject ID. Must be well-formed and unused; when null the server mints one.
26+
*/
27+
public ?string $id = null,
2328
) {
2429
}
2530

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace ProfessionalWiki\NeoWiki\Application;
6+
7+
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
8+
use ProfessionalWiki\NeoWiki\Infrastructure\IdGenerator;
9+
10+
/**
11+
* Mints batches of fresh, mutually distinct Subject IDs for client-side wiring of interlinked
12+
* imports. Generation stays server-owned so the ID scheme can evolve without breaking importers.
13+
*/
14+
readonly class SubjectIdMinter {
15+
16+
public function __construct(
17+
private IdGenerator $idGenerator,
18+
) {
19+
}
20+
21+
/**
22+
* @return list<SubjectId> exactly $count distinct ids
23+
*/
24+
public function mint( int $count ): array {
25+
$ids = [];
26+
27+
while ( count( $ids ) < $count ) {
28+
$id = SubjectId::createNew( $this->idGenerator );
29+
$ids[$id->text] = $id;
30+
}
31+
32+
return array_values( $ids );
33+
}
34+
35+
}

src/Domain/Page/PageSubjects.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,25 @@ public function createMainSubject( Subject $subject ): void {
9191
throw new RuntimeException( 'Main subject already exists' );
9292
}
9393

94+
if ( $this->childSubjects->hasSubject( $subject->id ) ) {
95+
throw new RuntimeException( 'Subject already exists' );
96+
}
97+
9498
$this->mainSubject = $subject;
9599
}
96100

97101
public function createChildSubject( Subject $subject ): void {
98-
if ( $this->childSubjects->hasSubject( $subject->id ) ) {
99-
throw new RuntimeException( 'Child subject already exists' );
102+
if ( $this->hasSubjectWithId( $subject->id ) ) {
103+
throw new RuntimeException( 'Subject already exists' );
100104
}
101105

102106
$this->childSubjects->addOrUpdateSubject( $subject );
103107
}
104108

109+
private function hasSubjectWithId( SubjectId $id ): bool {
110+
return $this->isMainSubject( $id ) || $this->childSubjects->hasSubject( $id );
111+
}
112+
105113
/**
106114
* Atomically set the main subject and child subject ordering. The set of
107115
* ids in $mainId (if non-null) and $childIds must match exactly the set of

src/EntryPoints/REST/CreateSubjectApi.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use MediaWiki\Rest\Response;
88
use MediaWiki\Rest\SimpleHandler;
99
use ProfessionalWiki\NeoWiki\Application\Actions\CreateSubject\CreateSubjectRequest;
10+
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
1011
use ProfessionalWiki\NeoWiki\NeoWikiExtension;
1112
use ProfessionalWiki\NeoWiki\Presentation\CsrfValidator;
1213
use ProfessionalWiki\NeoWiki\Presentation\RestCreateSubjectPresenter;
@@ -25,6 +26,15 @@ public function run( int $pageId ): Response {
2526

2627
$body = $this->getValidatedBody();
2728

29+
$id = $body['id'] ?? null;
30+
31+
if ( $id !== null && !SubjectId::isValid( $id ) ) {
32+
return $this->getResponseFactory()->createHttpError( 400, [
33+
'status' => 'error',
34+
'message' => "Subject ID has the wrong format: '$id'",
35+
] );
36+
}
37+
2838
$presenter = new RestCreateSubjectPresenter();
2939

3040
try {
@@ -36,6 +46,7 @@ public function run( int $pageId ): Response {
3646
schemaName: $body['schema'],
3747
statements: $body['statements'],
3848
comment: $body['comment'] ?? null,
49+
id: $id,
3950
)
4051
);
4152
} catch ( \InvalidArgumentException $e ) {
@@ -92,6 +103,14 @@ public function getBodyParamSettings(): array {
92103
ParamValidator::PARAM_REQUIRED => false,
93104
self::PARAM_DESCRIPTION => 'Optional edit summary.',
94105
],
106+
'id' => [
107+
self::PARAM_SOURCE => 'body',
108+
ParamValidator::PARAM_TYPE => 'string',
109+
ParamValidator::PARAM_REQUIRED => false,
110+
self::PARAM_DESCRIPTION => 'Optional Subject ID to assign. Must be a well-formed, unused '
111+
. 'Subject ID (malformed is rejected with 400, in-use with 409). When omitted, the server '
112+
. 'mints one. Pre-mint IDs with POST /neowiki/v0/subject-ids to wire relations across a batch.',
113+
],
95114
];
96115
}
97116

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace ProfessionalWiki\NeoWiki\EntryPoints\REST;
6+
7+
use MediaWiki\Rest\Response;
8+
use MediaWiki\Rest\SimpleHandler;
9+
use ProfessionalWiki\NeoWiki\Application\SubjectIdMinter;
10+
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
11+
use Wikimedia\ParamValidator\ParamValidator;
12+
13+
/**
14+
* Mints a batch of unused Subject IDs so an importer can wire relations between interlinked Subjects
15+
* before creating them. Stateless: no reservation, no graph dependency. See docs/api/subject-format.md.
16+
*/
17+
class MintSubjectIdsApi extends SimpleHandler {
18+
19+
private const int MIN_COUNT = 1;
20+
private const int MAX_COUNT = 1000;
21+
22+
public function __construct(
23+
private readonly SubjectIdMinter $subjectIdMinter,
24+
) {
25+
}
26+
27+
public function run(): Response {
28+
$count = $this->getValidatedBody()['count'];
29+
30+
if ( $count < self::MIN_COUNT || $count > self::MAX_COUNT ) {
31+
return $this->getResponseFactory()->createHttpError( 400, [
32+
'status' => 'error',
33+
'message' => 'count must be between ' . self::MIN_COUNT . ' and ' . self::MAX_COUNT . '.',
34+
] );
35+
}
36+
37+
return $this->getResponseFactory()->createJson( [
38+
'subjectIds' => array_map(
39+
static fn ( SubjectId $id ): string => $id->text,
40+
$this->subjectIdMinter->mint( $count )
41+
),
42+
] );
43+
}
44+
45+
public function getBodyParamSettings(): array {
46+
return [
47+
'count' => [
48+
self::PARAM_SOURCE => 'body',
49+
ParamValidator::PARAM_TYPE => 'integer',
50+
ParamValidator::PARAM_REQUIRED => true,
51+
self::PARAM_DESCRIPTION => 'Number of Subject IDs to mint, between '
52+
. self::MIN_COUNT . ' and ' . self::MAX_COUNT . ' inclusive.',
53+
],
54+
];
55+
}
56+
57+
public function needsWriteAccess(): bool {
58+
return false;
59+
}
60+
61+
}

src/NeoWikiExtension.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
use ProfessionalWiki\NeoWiki\Application\PageReadAuthorizer;
6161
use ProfessionalWiki\NeoWiki\Application\SubjectWriteAuthorizer;
6262
use ProfessionalWiki\NeoWiki\Application\SubjectPageRebuilder;
63+
use ProfessionalWiki\NeoWiki\Application\SubjectIdMinter;
6364
use ProfessionalWiki\NeoWiki\Application\SubjectRepository;
6465
use ProfessionalWiki\NeoWiki\Application\MappingLookup;
6566
use ProfessionalWiki\NeoWiki\Application\Rdf\OntologyMappingProjector;
@@ -93,6 +94,7 @@
9394
use ProfessionalWiki\NeoWiki\EntryPoints\REST\GetSchemaSummariesApi;
9495
use ProfessionalWiki\NeoWiki\EntryPoints\REST\GetSubjectApi;
9596
use ProfessionalWiki\NeoWiki\EntryPoints\REST\GetSubjectLabelsApi;
97+
use ProfessionalWiki\NeoWiki\EntryPoints\REST\MintSubjectIdsApi;
9698
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\EntryPoints\REST\CypherQueryApi;
9799
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\EntryPoints\REST\Neo4jRouteRegistration;
98100
use ProfessionalWiki\NeoWiki\EntryPoints\REST\ReplaceSubjectApi;
@@ -812,10 +814,15 @@ public function newCreateSubjectAction( CreateSubjectPresenter $presenter, Autho
812814
schemaLookup: $this->getSchemaLookup(),
813815
selectStatementResolver: $this->getSelectStatementResolver(),
814816
proposedSubjectValidator: $this->getProposedSubjectValidator(),
817+
pageIdentifiersLookup: $this->getPageIdentifiersLookup(),
815818
validationEnforced: $this->isValidationEnforced(),
816819
);
817820
}
818821

822+
public function newSubjectIdMinter(): SubjectIdMinter {
823+
return new SubjectIdMinter( $this->getIdGenerator() );
824+
}
825+
819826
public function getSelectStatementResolver(): SelectStatementResolver {
820827
return new SelectStatementResolver( new SelectValueResolver() );
821828
}
@@ -1090,6 +1097,12 @@ public static function newValidateSubjectApi(): ValidateSubjectApi {
10901097
);
10911098
}
10921099

1100+
public static function newMintSubjectIdsApi(): MintSubjectIdsApi {
1101+
return new MintSubjectIdsApi(
1102+
subjectIdMinter: self::getInstance()->newSubjectIdMinter(),
1103+
);
1104+
}
1105+
10931106
public static function newValidateSubjectUpdateApi(): ValidateSubjectUpdateApi {
10941107
return new ValidateSubjectUpdateApi();
10951108
}

0 commit comments

Comments
 (0)