Skip to content
Open
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
87 changes: 86 additions & 1 deletion docs/extending/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ Neo4j projection.
### Page Property Providers

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

```php
class StaticPagePropertyProvider implements PagePropertyProvider {
Expand Down Expand Up @@ -190,6 +191,36 @@ you to remove pages that are already gone from your store.
Examples: [`src/RedHerbSidebarHook.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/RedHerbSidebarHook.php)
and [`src/Specials/SpecialRedHerbSubjectFinder.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/Specials/SpecialRedHerbSubjectFinder.php).

### Running Cypher queries

To run a read-only Cypher query from PHP, use `NeoWikiExtension::getInstance()->newCypherQueryService()`.
It rejects write queries, enforces the timeout against the backend, and truncates results to the row cap;
resolve the limits configured in [`$wgNeoWikiQueryLimits`](../api/query-api.md) with
`Neo4jQueryLimits::forUser()`:

```php
$result = NeoWikiExtension::getInstance()->newCypherQueryService()->execute( new Neo4jQueryRequest(
cypher: 'MATCH (s:Subject:Person) WHERE s.`Birth year` > $minYear RETURN s.name AS name',
parameters: [ 'minYear' => 2000 ],
limits: Neo4jQueryLimits::forUser( $this->getUser() ),
) );
```

`execute()` returns a `Neo4jQueryResult` (columns, rows, truncation flag) and throws a `QueryException`
subclass on failure; `newCypherQueryService()` itself throws a `LogicException` on a wiki with no graph
backend configured.

The `User` in `forUser()` only sizes the limits: how heavy a single query may be, not whether the user may
query at all or how often. When running user-supplied queries, check the `neowiki-query` right and rate
limit yourself, as the [Query API](../api/query-api.md) endpoint does.

Two sharp edges: the write check is keyword-based and also rejects `CALL` and `SHOW`, even for read-only
procedures (see the [parser function notes](../authoring/parser-functions.md)). And the row cap truncates
the result only after the query has run in full, so bound expensive queries with `LIMIT` in the Cypher
itself.

Example: [`src/Specials/SpecialRedHerbContentPageCount.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php).

## Frontend extension points (JS/Vue)

NeoWiki's frontend is built with TypeScript and Vue. Extensions consume it as plain JavaScript and need no build step.
Expand Down Expand Up @@ -377,3 +408,57 @@ These extension points are designed or partially present but not yet open to ext
- **A published TypeScript types package.** TypeScript authors get types today by pointing their `tsconfig` at
NeoWiki's source (see "Authoring in TypeScript" and [ADR 24](../adr/024-frontend-extension-mechanism.md)). A
published, versioned package is deferred until a consumer needs types without a NeoWiki checkout.

## Internal surfaces

Everything on this page is alpha, but the surfaces below are internal even by that standard: they are
implementation details that happen to be reachable, and they can change in any release without notice.

### NeoWiki's rendered HTML

The `.ext-neowiki-view` placeholder elements and `data-mw-neowiki-*` attributes are the private contract
between NeoWiki's backend and its frontend for mounting Views. They are not an integration surface: do not
select these elements, read Subject IDs out of them, restyle their internals, or remove and replace them

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This forbids exactly what the repo's own copy-me example does.

tests/RedHerb/resources/editMainSubject/init.js:11:

const MAIN_SUBJECT_SELECTOR = '.ext-neowiki-view[data-mw-neowiki-subject-id]';

and :34/:38:

const el = document.querySelector( MAIN_SUBJECT_SELECTOR );
...
return el.dataset.mwNeowikiSubjectId || null;

That is "select these elements" and "read Subject IDs out of them" — both named in this sentence.

It isn't a distant contradiction. Lines 16-17 say "the fastest start is to copy the relevant RedHerb file and adapt it"; line 352 links resources/editMainSubject/; this section's own escape-hatch bullet routes readers to "Mounting standalone Vue features", where that example is listed. tests/RedHerb/README.md:37 links it too. And line 15 sells RedHerb as test-backed so "its examples stay working", which gives a reader no signal that this one is the forbidden pattern.

editMainSubject is the only RedHerb example doing this — createChild and subjectFinder are clean. Either carve it out by name here, or rework that example onto a supported path.

with your own rendering. Their structure can change in any release.

To control where and how a Subject renders:

- To place a Subject rendering in page content, use the
[`{{#view}}` parser function](../authoring/parser-functions.md), optionally with a Layout to control which
properties are shown.
- To render Subjects in your own visual format, register a custom View Type
(see [Registering a View Type frontend](#registering-a-view-type-frontend)).
- For fully custom UI outside the View system, fetch the data through the [REST API](../api/rest-api.md) or
the [public JS API](#using-neowikis-public-js-api) and render your own components, mounted as described in
[Mounting standalone Vue features](#mounting-standalone-vue-features). RedHerb's
[`editMainSubject`](https://github.com/ProfessionalWiki/NeoWiki/tree/master/tests/RedHerb/resources/editMainSubject)
resolves the page's Main Subject this way.

### The internal Neo4j client

`NeoWikiExtension::getInstance()->getNeo4jClient()` and `getReadOnlyNeo4jClient()` return the Laudis client
NeoWiki itself uses. Neo4j access is treated as an implementation detail of NeoWiki's persistence layer
([ADR 13](../adr/013-restrict-neo4j-access.md)). Nothing stops an extension from querying through the raw
client, but compare what the documented query interfaces
([`{{#cypher_raw}}`](../authoring/parser-functions.md), [`nw.query`](../authoring/lua-api.md), the
[Query API](../api/query-api.md), and the [PHP query service](#running-cypher-queries)) provide over it:

- **Read-only enforcement.** The query interfaces reject write queries (a keyword check plus `EXPLAIN`;
the keyword check also rejects read-only `CALL` and `SHOW`); with the raw client, a bug in calling code
can corrupt the graph projection, which is what ADR 13 exists to prevent.
- **Resource limits.** The query interfaces enforce a configured timeout against the backend and cap the
rows returned; the raw client has neither. The row cap does not bound the work a query does, so use
`LIMIT` in the Cypher either way.
- **Result handling.** The query interfaces return normalized rows and columns; the raw client returns
Laudis driver types that you convert yourself.
- **Churn exposure.** The query interfaces are documented contracts (alpha, like everything on this page);
the client accessors are internal wiring that can change or disappear in any release.

Writing to the graph directly deserves particular caution: the graph is a projection of wiki content that
NeoWiki rewrites at will. Saving a page that carries the Subject slot re-projects that page's nodes, and
the `RebuildGraphDatabases` maintenance script rebuilds the projection from scratch, so anything a third
party writes into the graph can be overwritten, orphaned, or deleted at any time. For page-level key/value
metadata there is a durable path: [Page Property Providers](#page-property-providers), which NeoWiki
re-runs whenever a Subject-slot page is saved or rebuilt. For arbitrary nodes and relationships there is
currently no durable third-party write path into NeoWiki's graph; a
[Graph Database Backend](#graph-database-backends) projects durably, but into its own store.
2 changes: 2 additions & 0 deletions phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<file>src/</file>
<file>tests/</file>

<exclude-pattern>tests/RedHerb/node_modules/</exclude-pattern>

<rule ref="./vendor/mediawiki/mediawiki-codesniffer/MediaWiki">
<exclude name="Generic.Files.LineLength.TooLong" />
<exclude name="MediaWiki.Commenting.FunctionComment" />
Expand Down
2 changes: 2 additions & 0 deletions tests/RedHerb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ extension point to the file that demonstrates it.
[`src/RedHerbFrontendModulesHook.php`](src/RedHerbFrontendModulesHook.php).
- **Reading NeoWiki data / authorization** — [`src/RedHerbSidebarHook.php`](src/RedHerbSidebarHook.php) and
[`src/Specials/SpecialRedHerbSubjectFinder.php`](src/Specials/SpecialRedHerbSubjectFinder.php).
- **Running Cypher queries (PHP query service)** —
[`src/Specials/SpecialRedHerbContentPageCount.php`](src/Specials/SpecialRedHerbContentPageCount.php).

### Frontend (JS/Vue)

Expand Down
3 changes: 3 additions & 0 deletions tests/RedHerb/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
"SpecialPages": {
"RedHerbSubjectFinder": {
"class": "ProfessionalWiki\\RedHerb\\Specials\\SpecialRedHerbSubjectFinder"
},
"RedHerbContentPageCount": {
"class": "ProfessionalWiki\\RedHerb\\Specials\\SpecialRedHerbContentPageCount"
}
},

Expand Down
1 change: 1 addition & 0 deletions tests/RedHerb/i18n/_Aliases.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@

$specialPageAliases['en'] = [
'RedHerbSubjectFinder' => [ 'RedHerbSubjectFinder' ],
'RedHerbContentPageCount' => [ 'RedHerbContentPageCount' ],
];
3 changes: 3 additions & 0 deletions tests/RedHerb/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"redherb-sidebar-create-child-company": "Create child Company",
"redherb-sidebar-subject-finder": "Find a subject",
"redherb-special-subject-finder": "RedHerb subject finder",
"redherb-special-content-page-count": "RedHerb content page count",
"redherb-content-page-count": "Content namespace pages tracked in the graph: $1",
"redherb-content-page-count-error": "Could not count content namespace pages tracked in the graph: $1",
"redherb-subject-finder-schema-label": "Schema",
"redherb-subject-finder-schema-placeholder": "Enter a schema name",
"redherb-subject-finder-pick-subject": "Pick a subject",
Expand Down
3 changes: 3 additions & 0 deletions tests/RedHerb/i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"redherb-sidebar-create-child-company": "Text of the sidebar link that opens a dialog for creating a child Subject with the Company schema.",
"redherb-sidebar-subject-finder": "Text of the sidebar link that opens the RedHerb subject finder.",
"redherb-special-subject-finder": "Title shown on Special:RedHerbSubjectFinder.",
"redherb-special-content-page-count": "Title shown on Special:RedHerbContentPageCount.",
"redherb-content-page-count": "Result shown on Special:RedHerbContentPageCount, reporting how many content-namespace pages NeoWiki tracks in the graph. $1 is the count.",
"redherb-content-page-count-error": "Error shown on Special:RedHerbContentPageCount when the Cypher query fails. $1 is the underlying error message.",
"redherb-subject-finder-schema-label": "Label of the schema name input on Special:RedHerbSubjectFinder.\n{{Identical|Schema}}",
"redherb-subject-finder-schema-placeholder": "Placeholder text inside the schema name input on Special:RedHerbSubjectFinder.",
"redherb-subject-finder-pick-subject": "Label of the subject lookup on Special:RedHerbSubjectFinder.",
Expand Down
40 changes: 27 additions & 13 deletions tests/RedHerb/resources/editMainSubject/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
const DIALOG_STATE_KEY = require( './constants.js' ).DIALOG_STATE_KEY;

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

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

function resolveMainSubjectId() {
const el = document.querySelector( MAIN_SUBJECT_SELECTOR );
if ( el === null ) {
return null;
const pageId = mw.config.get( 'wgArticleId' );
if ( !pageId ) {
return Promise.resolve( null );
}
return el.dataset.mwNeowikiSubjectId || null;
return nw.NeoWikiExtension.getInstance().getSubjectRepository().getPageSubjects( pageId )
.then( ( result ) => {
const mainSubjectId = result.pageSubjects.getMainSubjectId();
return mainSubjectId === null ? null : mainSubjectId.text;
} );
}

function handleClick( ev ) {
const trigger = ev.target.closest( TRIGGER_SELECTOR );
if ( trigger === null ) {
return;
}
ev.preventDefault();

const subjectId = resolveMainSubjectId();
function openDialog( subjectId ) {
if ( subjectId === null ) {
mw.notify(
mw.message( 'redherb-edit-main-subject-no-main' ).text(),
Expand All @@ -59,6 +55,24 @@
dialogState.open = true;
}

function handleClick( ev ) {
const trigger = ev.target.closest( TRIGGER_SELECTOR );
if ( trigger === null ) {
return;
}
ev.preventDefault();

resolveMainSubjectId()
.then( openDialog )
.catch( ( err ) => {
mw.log.error( err );
mw.notify(
err instanceof Error ? err.message : String( err ),
{ type: 'error' }
);
} );
}

queueMicrotask( () => {
document.body.addEventListener( 'click', handleClick );
} );
Expand Down
46 changes: 46 additions & 0 deletions tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\RedHerb\Specials;

use Exception;
use MediaWiki\Message\Message;
use MediaWiki\SpecialPage\SpecialPage;
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Application\Neo4jQueryLimits;
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Application\Neo4jQueryRequest;
use ProfessionalWiki\NeoWiki\NeoWikiExtension;

class SpecialRedHerbContentPageCount extends SpecialPage {

public function __construct() {
parent::__construct( 'RedHerbContentPageCount' );
}

/**
* @param ?string $subPage
*/
public function execute( $subPage ): void {
parent::execute( $subPage );

$out = $this->getOutput();

try {
$result = NeoWikiExtension::getInstance()->newCypherQueryService()->execute( new Neo4jQueryRequest(
cypher: 'MATCH (page:Page) WHERE page.namespaceId = $namespaceId RETURN count(page) AS pageCount',
parameters: [ 'namespaceId' => NS_MAIN ],
limits: Neo4jQueryLimits::forUser( $this->getUser() ),
) );
} catch ( Exception $e ) {
$out->addWikiMsg( 'redherb-content-page-count-error', $e->getMessage() );
return;
}

$out->addWikiMsg( 'redherb-content-page-count', $result->rows[0]['pageCount'] );
}

public function getDescription(): Message {
return $this->msg( 'redherb-special-content-page-count' );
}

}
60 changes: 60 additions & 0 deletions tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Tests\RedHerb;

use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\Output\OutputPage;
use MediaWiki\Title\Title;
use ProfessionalWiki\NeoWiki\Tests\Data\TestSubject;
use ProfessionalWiki\NeoWiki\Tests\NeoWikiIntegrationTestCase;
use ProfessionalWiki\RedHerb\Specials\SpecialRedHerbContentPageCount;

/**
* @covers \ProfessionalWiki\RedHerb\Specials\SpecialRedHerbContentPageCount
* @group Database
*/
class SpecialRedHerbContentPageCountTest extends NeoWikiIntegrationTestCase {

protected function setUp(): void {
parent::setUp();
$this->setUpNeo4j();
$this->createSchema( TestSubject::DEFAULT_SCHEMA_ID );
$this->markPageTableAsUsed();
}

public function testCountsOnlyContentNamespacePagesTrackedInTheGraph(): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error branch at SpecialRedHerbContentPageCount.php:34-37, and the redherb-content-page-count-error message, are never exercised — this is the only test method. Deleting the try/catch or renaming the i18n key leaves the suite green.

The helper already exists: wrapping executeContentPageCount() in $this->runWithoutGraphBackend( ... ) (tests/phpunit/NeoWikiIntegrationTestCase.php:142) nulls both Neo4j URLs and resets the singleton, so newCypherQueryService() throws inside the try and drives the catch. Precedent for that helper on no-backend paths: tests/phpunit/NoGraphBackendTest.php.

Minor, since RedHerb is example code — but this PR's stated purpose is keeping the documented call shape working, and extending.md:208 asserts this exact no-backend contract.

$this->createPageWithSubjects( 'RedHerb content page one', TestSubject::build( id: 'sRedHerbCP11111' ) );
$this->createPageWithSubjects( 'RedHerb content page two', TestSubject::build( id: 'sRedHerbCP22222' ) );
$this->createPageWithSubjects( 'Help:RedHerb help page', TestSubject::build( id: 'sRedHerbHP33333' ) );

$this->assertStringContainsString(
'Content namespace pages tracked in the graph: 2',
$this->executeContentPageCount()
);
}

public function testErrorOutputIncludesUnderlyingCauseWhenGraphBackendMissing(): void {
$html = $this->runWithoutGraphBackend(
fn() => $this->executeContentPageCount()
);

$this->assertStringContainsString( 'A configured Neo4j backend is required', $html );
}

private function executeContentPageCount(): string {
$context = new DerivativeContext( RequestContext::getMain() );
$context->setTitle( Title::makeTitle( NS_SPECIAL, 'RedHerbContentPageCount' ) );
$output = new OutputPage( $context );
$context->setOutput( $output );

$page = new SpecialRedHerbContentPageCount();
$page->setContext( $context );
$page->execute( null );

return $output->getHTML();
}

}
Loading