Skip to content

Commit 471bd58

Browse files
malbertsclaudeJeroenDeDauw
authored
Document internal surfaces and the PHP Cypher query path (#1045)
* Document internal surfaces and the PHP Cypher query path Extensions have started building on two surfaces that are reachable but internal: the rendered .ext-neowiki-view placeholders and the raw Neo4j client. NeoWiki is pre-1.0 and cannot yet declare a stable interface, so the extending docs gain an "Internal surfaces" section: the rendered HTML is marked as not an integration surface, while the internal client gets a comparison of what the documented query interfaces provide over it (read-only enforcement, resource limits, result handling, churn exposure) rather than a prohibition, plus a warning that direct graph writes do not survive re-projection or rebuilds. Since the alternative for PHP-side querying was undocumented, add a "Running Cypher queries" subsection covering newCypherQueryService(), backed by a new RedHerb example (SpecialRedHerbContentPageCount) with an integration test against real Neo4j, so the documented call shape stays working like the page's other examples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop the apihighlimits mention from the query limits sentence The tier mechanics are documented on the linked Query API page; naming the right here duplicated that and it is default MediaWiki behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review precision findings on the query docs and example Docs: name the keyword validator's CALL and SHOW rejection, state that the row cap truncates only after the query has run (bound work with LIMIT in the Cypher), scope the durable-write pointer to page-level key/value metadata and state plainly that arbitrary graph structure has no durable third-party write path, and qualify projection on save and rebuild with the Subject-slot requirement. Example: bind the caught exception and surface its message like the Scribunto library does, cover the no-backend error branch via runWithoutGraphBackend(), and rework editMainSubject to resolve the Main Subject through getSubjectRepository()->getPageSubjects() instead of scraping the .ext-neowiki-view placeholder the docs forbid; the docs now cite it as the worked example of that path. Also exclude tests/RedHerb/node_modules from phpcs: following the RedHerb README's npm install instructions previously made make phpcs scan thousands of dependency files and run out of memory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Tighten internal-surfaces wording from the text-review pass Name Neo4j in the no-backend LogicException sentence (it matches the thrown message and stays exact once SPARQL stores can be configured alongside), state that editMainSubject resolves the Main Subject through the public JS API instead of the ambiguous "this way", and cut a sentence restating the section intro's change-without-notice fact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jeroen De Dauw <jeroendedauw@proton.me>
1 parent c8942f5 commit 471bd58

10 files changed

Lines changed: 233 additions & 14 deletions

File tree

docs/extending/extending.md

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ Neo4j projection.
106106
### Page Property Providers
107107

108108
Page Property Providers contribute key/value metadata to the Page node in the graph (queryable via Cypher;
109-
Neo4j is currently the only graph backend). Implement `PagePropertyProvider`:
109+
Neo4j is currently the only graph backend). Providers run when a page carrying the NeoWiki subject slot is
110+
saved or rebuilt; pages without Subjects are not projected. Implement `PagePropertyProvider`:
110111

111112
```php
112113
class StaticPagePropertyProvider implements PagePropertyProvider {
@@ -198,6 +199,36 @@ you to remove pages that are already gone from your store.
198199
Examples: [`src/RedHerbSidebarHook.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/RedHerbSidebarHook.php)
199200
and [`src/Specials/SpecialRedHerbSubjectFinder.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/Specials/SpecialRedHerbSubjectFinder.php).
200201

202+
### Running Cypher queries
203+
204+
To run a read-only Cypher query from PHP, use `NeoWikiExtension::getInstance()->newCypherQueryService()`.
205+
It rejects write queries, enforces the timeout against the backend, and truncates results to the row cap;
206+
resolve the limits configured in [`$wgNeoWikiQueryLimits`](../api/query-api.md) with
207+
`Neo4jQueryLimits::forUser()`:
208+
209+
```php
210+
$result = NeoWikiExtension::getInstance()->newCypherQueryService()->execute( new Neo4jQueryRequest(
211+
cypher: 'MATCH (s:Subject:Person) WHERE s.`Birth year` > $minYear RETURN s.name AS name',
212+
parameters: [ 'minYear' => 2000 ],
213+
limits: Neo4jQueryLimits::forUser( $this->getUser() ),
214+
) );
215+
```
216+
217+
`execute()` returns a `Neo4jQueryResult` (columns, rows, truncation flag) and throws a `QueryException`
218+
subclass on failure; `newCypherQueryService()` itself throws a `LogicException` on a wiki with no Neo4j
219+
backend configured.
220+
221+
The `User` in `forUser()` only sizes the limits: how heavy a single query may be, not whether the user may
222+
query at all or how often. When running user-supplied queries, check the `neowiki-query` right and rate
223+
limit yourself, as the [Query API](../api/query-api.md) endpoint does.
224+
225+
Two sharp edges: the write check is keyword-based and also rejects `CALL` and `SHOW`, even for read-only
226+
procedures (see the [parser function notes](../authoring/parser-functions.md)). And the row cap truncates
227+
the result only after the query has run in full, so bound expensive queries with `LIMIT` in the Cypher
228+
itself.
229+
230+
Example: [`src/Specials/SpecialRedHerbContentPageCount.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php).
231+
201232
## Frontend extension points (JS/Vue)
202233

203234
NeoWiki's frontend is built with TypeScript and Vue. Extensions consume it as plain JavaScript and need no build step.
@@ -385,3 +416,57 @@ These extension points are designed or partially present but not yet open to ext
385416
- **A published TypeScript types package.** TypeScript authors get types today by pointing their `tsconfig` at
386417
NeoWiki's source (see "Authoring in TypeScript" and [ADR 24](../adr/024-frontend-extension-mechanism.md)). A
387418
published, versioned package is deferred until a consumer needs types without a NeoWiki checkout.
419+
420+
## Internal surfaces
421+
422+
Everything on this page is alpha, but the surfaces below are internal even by that standard: they are
423+
implementation details that happen to be reachable, and they can change in any release without notice.
424+
425+
### NeoWiki's rendered HTML
426+
427+
The `.ext-neowiki-view` placeholder elements and `data-mw-neowiki-*` attributes are the private contract
428+
between NeoWiki's backend and its frontend for mounting Views. They are not an integration surface: do not
429+
select these elements, read Subject IDs out of them, restyle their internals, or remove and replace them
430+
with your own rendering.
431+
432+
To control where and how a Subject renders:
433+
434+
- To place a Subject rendering in page content, use the
435+
[`{{#view}}` parser function](../authoring/parser-functions.md), optionally with a Layout to control which
436+
properties are shown.
437+
- To render Subjects in your own visual format, register a custom View Type
438+
(see [Registering a View Type frontend](#registering-a-view-type-frontend)).
439+
- For fully custom UI outside the View system, fetch the data through the [REST API](../api/rest-api.md) or
440+
the [public JS API](#using-neowikis-public-js-api) and render your own components, mounted as described in
441+
[Mounting standalone Vue features](#mounting-standalone-vue-features). RedHerb's
442+
[`editMainSubject`](https://github.com/ProfessionalWiki/NeoWiki/tree/master/tests/RedHerb/resources/editMainSubject)
443+
resolves the page's Main Subject through the public JS API.
444+
445+
### The internal Neo4j client
446+
447+
`NeoWikiExtension::getInstance()->getNeo4jClient()` and `getReadOnlyNeo4jClient()` return the Laudis client
448+
NeoWiki itself uses. Neo4j access is treated as an implementation detail of NeoWiki's persistence layer
449+
([ADR 13](../adr/013-restrict-neo4j-access.md)). Nothing stops an extension from querying through the raw
450+
client, but compare what the documented query interfaces
451+
([`{{#cypher_raw}}`](../authoring/parser-functions.md), [`nw.query`](../authoring/lua-api.md), the
452+
[Query API](../api/query-api.md), and the [PHP query service](#running-cypher-queries)) provide over it:
453+
454+
- **Read-only enforcement.** The query interfaces reject write queries (a keyword check plus `EXPLAIN`;
455+
the keyword check also rejects read-only `CALL` and `SHOW`); with the raw client, a bug in calling code
456+
can corrupt the graph projection, which is what ADR 13 exists to prevent.
457+
- **Resource limits.** The query interfaces enforce a configured timeout against the backend and cap the
458+
rows returned; the raw client has neither. The row cap does not bound the work a query does, so use
459+
`LIMIT` in the Cypher either way.
460+
- **Result handling.** The query interfaces return normalized rows and columns; the raw client returns
461+
Laudis driver types that you convert yourself.
462+
- **Churn exposure.** The query interfaces are documented contracts (alpha, like everything on this page);
463+
the client accessors are internal wiring that can change or disappear in any release.
464+
465+
Writing to the graph directly deserves particular caution: the graph is a projection of wiki content that
466+
NeoWiki rewrites at will. Saving a page that carries the Subject slot re-projects that page's nodes, and
467+
the `RebuildGraphDatabases` maintenance script rebuilds the projection from scratch, so anything a third
468+
party writes into the graph can be overwritten, orphaned, or deleted at any time. For page-level key/value
469+
metadata there is a durable path: [Page Property Providers](#page-property-providers), which NeoWiki
470+
re-runs whenever a Subject-slot page is saved or rebuilt. For arbitrary nodes and relationships there is
471+
currently no durable third-party write path into NeoWiki's graph; a
472+
[Graph Database Backend](#graph-database-backends) projects durably, but into its own store.

phpcs.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
<file>src/</file>
44
<file>tests/</file>
55

6+
<exclude-pattern>tests/RedHerb/node_modules/</exclude-pattern>
7+
68
<rule ref="./vendor/mediawiki/mediawiki-codesniffer/MediaWiki">
79
<exclude name="Generic.Files.LineLength.TooLong" />
810
<exclude name="MediaWiki.Commenting.FunctionComment" />

tests/RedHerb/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ extension point to the file that demonstrates it.
2424
[`src/RedHerbFrontendModulesHook.php`](src/RedHerbFrontendModulesHook.php).
2525
- **Reading NeoWiki data / authorization**[`src/RedHerbSidebarHook.php`](src/RedHerbSidebarHook.php) and
2626
[`src/Specials/SpecialRedHerbSubjectFinder.php`](src/Specials/SpecialRedHerbSubjectFinder.php).
27+
- **Running Cypher queries (PHP query service)**
28+
[`src/Specials/SpecialRedHerbContentPageCount.php`](src/Specials/SpecialRedHerbContentPageCount.php).
2729

2830
### Frontend (JS/Vue)
2931

tests/RedHerb/extension.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
"SpecialPages": {
4848
"RedHerbSubjectFinder": {
4949
"class": "ProfessionalWiki\\RedHerb\\Specials\\SpecialRedHerbSubjectFinder"
50+
},
51+
"RedHerbContentPageCount": {
52+
"class": "ProfessionalWiki\\RedHerb\\Specials\\SpecialRedHerbContentPageCount"
5053
}
5154
},
5255

tests/RedHerb/i18n/_Aliases.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44

55
$specialPageAliases['en'] = [
66
'RedHerbSubjectFinder' => [ 'RedHerbSubjectFinder' ],
7+
'RedHerbContentPageCount' => [ 'RedHerbContentPageCount' ],
78
];

tests/RedHerb/i18n/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"redherb-sidebar-create-child-company": "Create child Company",
77
"redherb-sidebar-subject-finder": "Find a subject",
88
"redherb-special-subject-finder": "RedHerb subject finder",
9+
"redherb-special-content-page-count": "RedHerb content page count",
10+
"redherb-content-page-count": "Content namespace pages tracked in the graph: $1",
11+
"redherb-content-page-count-error": "Could not count content namespace pages tracked in the graph: $1",
912
"redherb-subject-finder-schema-label": "Schema",
1013
"redherb-subject-finder-schema-placeholder": "Enter a schema name",
1114
"redherb-subject-finder-pick-subject": "Pick a subject",

tests/RedHerb/i18n/qqq.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"redherb-sidebar-create-child-company": "Text of the sidebar link that opens a dialog for creating a child Subject with the Company schema.",
77
"redherb-sidebar-subject-finder": "Text of the sidebar link that opens the RedHerb subject finder.",
88
"redherb-special-subject-finder": "Title shown on Special:RedHerbSubjectFinder.",
9+
"redherb-special-content-page-count": "Title shown on Special:RedHerbContentPageCount.",
10+
"redherb-content-page-count": "Result shown on Special:RedHerbContentPageCount, reporting how many content-namespace pages NeoWiki tracks in the graph. $1 is the count.",
11+
"redherb-content-page-count-error": "Error shown on Special:RedHerbContentPageCount when the Cypher query fails. $1 is the underlying error message.",
912
"redherb-subject-finder-schema-label": "Label of the schema name input on Special:RedHerbSubjectFinder.\n{{Identical|Schema}}",
1013
"redherb-subject-finder-schema-placeholder": "Placeholder text inside the schema name input on Special:RedHerbSubjectFinder.",
1114
"redherb-subject-finder-pick-subject": "Label of the subject lookup on Special:RedHerbSubjectFinder.",

tests/RedHerb/resources/editMainSubject/init.js

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
const DIALOG_STATE_KEY = require( './constants.js' ).DIALOG_STATE_KEY;
99

1010
const TRIGGER_SELECTOR = '.ext-redherb-edit-main-subject-trigger';
11-
const MAIN_SUBJECT_SELECTOR = '.ext-neowiki-view[data-mw-neowiki-subject-id]';
1211

1312
const dialogState = Vue.reactive( { open: false, subjectId: null } );
1413
let mounted = false;
@@ -31,21 +30,18 @@
3130
}
3231

3332
function resolveMainSubjectId() {
34-
const el = document.querySelector( MAIN_SUBJECT_SELECTOR );
35-
if ( el === null ) {
36-
return null;
33+
const pageId = mw.config.get( 'wgArticleId' );
34+
if ( !pageId ) {
35+
return Promise.resolve( null );
3736
}
38-
return el.dataset.mwNeowikiSubjectId || null;
37+
return nw.NeoWikiExtension.getInstance().getSubjectRepository().getPageSubjects( pageId )
38+
.then( ( result ) => {
39+
const mainSubjectId = result.pageSubjects.getMainSubjectId();
40+
return mainSubjectId === null ? null : mainSubjectId.text;
41+
} );
3942
}
4043

41-
function handleClick( ev ) {
42-
const trigger = ev.target.closest( TRIGGER_SELECTOR );
43-
if ( trigger === null ) {
44-
return;
45-
}
46-
ev.preventDefault();
47-
48-
const subjectId = resolveMainSubjectId();
44+
function openDialog( subjectId ) {
4945
if ( subjectId === null ) {
5046
mw.notify(
5147
mw.message( 'redherb-edit-main-subject-no-main' ).text(),
@@ -59,6 +55,24 @@
5955
dialogState.open = true;
6056
}
6157

58+
function handleClick( ev ) {
59+
const trigger = ev.target.closest( TRIGGER_SELECTOR );
60+
if ( trigger === null ) {
61+
return;
62+
}
63+
ev.preventDefault();
64+
65+
resolveMainSubjectId()
66+
.then( openDialog )
67+
.catch( ( err ) => {
68+
mw.log.error( err );
69+
mw.notify(
70+
err instanceof Error ? err.message : String( err ),
71+
{ type: 'error' }
72+
);
73+
} );
74+
}
75+
6276
queueMicrotask( () => {
6377
document.body.addEventListener( 'click', handleClick );
6478
} );
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace ProfessionalWiki\RedHerb\Specials;
6+
7+
use Exception;
8+
use MediaWiki\Message\Message;
9+
use MediaWiki\SpecialPage\SpecialPage;
10+
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Application\Neo4jQueryLimits;
11+
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Application\Neo4jQueryRequest;
12+
use ProfessionalWiki\NeoWiki\NeoWikiExtension;
13+
14+
class SpecialRedHerbContentPageCount extends SpecialPage {
15+
16+
public function __construct() {
17+
parent::__construct( 'RedHerbContentPageCount' );
18+
}
19+
20+
/**
21+
* @param ?string $subPage
22+
*/
23+
public function execute( $subPage ): void {
24+
parent::execute( $subPage );
25+
26+
$out = $this->getOutput();
27+
28+
try {
29+
$result = NeoWikiExtension::getInstance()->newCypherQueryService()->execute( new Neo4jQueryRequest(
30+
cypher: 'MATCH (page:Page) WHERE page.namespaceId = $namespaceId RETURN count(page) AS pageCount',
31+
parameters: [ 'namespaceId' => NS_MAIN ],
32+
limits: Neo4jQueryLimits::forUser( $this->getUser() ),
33+
) );
34+
} catch ( Exception $e ) {
35+
$out->addWikiMsg( 'redherb-content-page-count-error', $e->getMessage() );
36+
return;
37+
}
38+
39+
$out->addWikiMsg( 'redherb-content-page-count', $result->rows[0]['pageCount'] );
40+
}
41+
42+
public function getDescription(): Message {
43+
return $this->msg( 'redherb-special-content-page-count' );
44+
}
45+
46+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace ProfessionalWiki\NeoWiki\Tests\RedHerb;
6+
7+
use MediaWiki\Context\DerivativeContext;
8+
use MediaWiki\Context\RequestContext;
9+
use MediaWiki\Output\OutputPage;
10+
use MediaWiki\Title\Title;
11+
use ProfessionalWiki\NeoWiki\Tests\Data\TestSubject;
12+
use ProfessionalWiki\NeoWiki\Tests\NeoWikiIntegrationTestCase;
13+
use ProfessionalWiki\RedHerb\Specials\SpecialRedHerbContentPageCount;
14+
15+
/**
16+
* @covers \ProfessionalWiki\RedHerb\Specials\SpecialRedHerbContentPageCount
17+
* @group Database
18+
*/
19+
class SpecialRedHerbContentPageCountTest extends NeoWikiIntegrationTestCase {
20+
21+
protected function setUp(): void {
22+
parent::setUp();
23+
$this->setUpNeo4j();
24+
$this->createSchema( TestSubject::DEFAULT_SCHEMA_ID );
25+
$this->markPageTableAsUsed();
26+
}
27+
28+
public function testCountsOnlyContentNamespacePagesTrackedInTheGraph(): void {
29+
$this->createPageWithSubjects( 'RedHerb content page one', TestSubject::build( id: 'sRedHerbCP11111' ) );
30+
$this->createPageWithSubjects( 'RedHerb content page two', TestSubject::build( id: 'sRedHerbCP22222' ) );
31+
$this->createPageWithSubjects( 'Help:RedHerb help page', TestSubject::build( id: 'sRedHerbHP33333' ) );
32+
33+
$this->assertStringContainsString(
34+
'Content namespace pages tracked in the graph: 2',
35+
$this->executeContentPageCount()
36+
);
37+
}
38+
39+
public function testErrorOutputIncludesUnderlyingCauseWhenGraphBackendMissing(): void {
40+
$html = $this->runWithoutGraphBackend(
41+
fn() => $this->executeContentPageCount()
42+
);
43+
44+
$this->assertStringContainsString( 'A configured Neo4j backend is required', $html );
45+
}
46+
47+
private function executeContentPageCount(): string {
48+
$context = new DerivativeContext( RequestContext::getMain() );
49+
$context->setTitle( Title::makeTitle( NS_SPECIAL, 'RedHerbContentPageCount' ) );
50+
$output = new OutputPage( $context );
51+
$context->setOutput( $output );
52+
53+
$page = new SpecialRedHerbContentPageCount();
54+
$page->setContext( $context );
55+
$page->execute( null );
56+
57+
return $output->getHTML();
58+
}
59+
60+
}

0 commit comments

Comments
 (0)