Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
73 changes: 73 additions & 0 deletions docs/extending/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,31 @@ 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 and enforces the timeout and row cap you pass as limits; resolve the values
configured in [`$wgNeoWikiQueryLimits`](../api/query-api.md) with `Neo4jQueryLimits::forUser()` (users
with `apihighlimits` get the higher tier):
Comment thread
malberts marked this conversation as resolved.
Outdated

```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.

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 +402,51 @@ 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).

### 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 (keyword check plus `EXPLAIN`);

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.

"reject write queries" — and "It rejects write queries" at line 196 — understates the validator. KeywordCypherQueryValidator::WRITE_KEYWORDS also contains CALL and SHOW:

private const array WRITE_KEYWORDS = [
	'CREATE', 'SET', 'DELETE', 'REMOVE', 'MERGE', 'DROP',
	'CALL', 'LOAD', 'FOREACH',
	'GRANT', 'DENY', 'REVOKE',
	'SHOW',
];

So a genuinely read-only CALL {} subquery, db.labels(), or SHOW INDEXES throws WriteQueryRejectedException( 'Query is not read-only.' ). Concretely, this reads nothing but nodes and is rejected:

MATCH (p:Page) CALL { WITH p MATCH (p)-[:HasSubject]->(s:Subject) RETURN count(s) AS c } RETURN p.name, c

Since this bullet lists read-only enforcement as pure upside over the raw client, the reader's rational conclusion is that their query is a write — or that they must drop to the internal client this section just forbade.

The caveat exists exactly once in the repo, at docs/authoring/parser-functions.md:148 ("including CALL (even for read-only procedures)"). Worth naming CALL/SHOW here, or linking that note.

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 apply a configured timeout and row cap; the raw client runs

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.

Only the timeout is enforced against the backend; maxRows never reaches the driver.

Neo4jClientReadQueryEngine::runReadQuery() passes just the timeout:

$transactionConfig = $timeoutSeconds === null
	? null
	: TransactionConfiguration::default()->withTimeout( (float)$timeoutSeconds );

No LIMIT injection, no row bound. maxRows appears only in Neo4jQueryLimits and in Neo4jQueryService::buildResult(), which materializes the full SummarizedResult, counts it, normalizes every row via convertRows(), and only then array_slice()s to maxRows.

So against "the raw client runs unbounded queries", an author who ships MATCH (s:Subject) RETURN s trusting the 5,000-row default gets a PHP memory-exhaustion fatal on a large graph — not 5,000 rows with truncated: true. That is the same outcome the raw client would give, so the row cap isn't the protection this bullet implies. docs/api/query-api.md:215 already frames it accurately: "the result was cut at maxRows".

Suggest splitting the claim: the timeout is enforced; the row cap truncates the returned result, and callers should still put LIMIT in the Cypher. Same for "enforces the timeout and row cap" at line 196.

unbounded queries.
- **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 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. To contribute data to the graph durably, use

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.

Page Property Providers don't cover the case this paragraph is about.

The paragraph's subject is an extension reaching for the raw client to write arbitrary nodes and relationships. But PagePropertyProvider::getProperties() returns array<string, mixed>, merged onto a single Page node:

/**
 * @return array<string, mixed>
 */
public function getProperties( PagePropertyProviderContext $context ): array;

No way to create a node, create a relationship, or attach anything to a Subject. A reader whose case is "create a node per external record and link it to the Subjects that reference it" follows this pointer and hits a dead end — for the exact case that motivated reaching for the raw client.

Suggest scoping the sentence (e.g. "for page-level key/value metadata, use Page Property Providers") and saying what covers the rest. Worth noting the honest answer is a bit awkward: Graph Database Backends (line 146) projects arbitrary structure durably, but into your own store alongside Neo4j — so NeoWiki's own Neo4j graph genuinely has no durable third-party write path for arbitrary nodes. If that's the intended message, it'd be stronger stated outright than implied.

[Page Property Providers](#page-property-providers), which NeoWiki re-runs on every save and rebuild.

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.

"on every save and rebuild" needs a qualifier: both paths only reach pages carrying the NeoWiki subject slot.

OnRevisionCreatedHandler::storeRevisionRecord() returns before PagePropertiesBuilder is ever called:

$neoContent = $this->getNeoContent( $revisionRecord );
if ( $neoContent === null ) {
	return false;
}

with getNeoContent() returning null when !$revisionRecord->hasSlot( MediaWikiSubjectRepository::SLOT_NAME ). The rebuild doesn't rescue it either: RebuildGraphDatabases::getSubjectPageIds() joins slots on slot_role_id, docblocked "Page IDs of every page whose latest revision carries the NeoWiki subject slot", so a slot-less page is never enumerated.

This section's own myext_reviewState example is exactly the case that would bite: an approval extension wanting the property on every content page finds it silently absent on every page without Subjects. The constraint is documented — at line 142, SkippedMissingSubjectSlot — but only for the manual rebuild() path, not here or in the Page Property Providers section itself.

The same qualifier applies to "Saving a page re-projects that page's nodes" on line 448.

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.",
"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.",
"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
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 ) {

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.

catch ( Exception ) with no binding discards the exception, so an unconfigured backend and a typo in this file's own Cypher render the identical generic message with nothing logged.

On a wiki with no graph backend, newCypherQueryService() calls requireNeo4jPlugin(), which throws LogicException( 'A configured Neo4j backend is required here.' ). LogicException extends Exception, so it lands here and surfaces as "Could not count content namespace pages tracked in the graph." — no hint that Neo4j is simply unconfigured. A CypherSyntaxException from the query on line 30 gets the same treatment.

The broad catch itself looks right: the page is registered unconditionally in extension.json, so narrowing to QueryException would turn the unconfigured-wiki case into a fatal error page. The suggestion is just to bind $e and surface or log its message — which is what the repo's only other consumer of this service does in its broad fallback (ScribuntoLuaLibrary.php:133-134):

} catch ( Exception $e ) {
	throw new LuaError( $e->getMessage() );
}

optionally catching QueryException first for a typed message, as that same file does at line 130.

Worth slightly more than usual here because the docs added in this PR carefully distinguish the two failure types (line 208): "throws a QueryException subclass on failure; newCypherQueryService() itself throws a LogicException on a wiki with no graph backend configured." The example swallows the distinction the docs just drew.

$out->addWikiMsg( 'redherb-content-page-count-error' );
return;
}

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

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

}
52 changes: 52 additions & 0 deletions tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?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()
);
}

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