-
Notifications
You must be signed in to change notification settings - Fork 5
Document internal surfaces and the PHP Cypher query path #1045
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -190,6 +190,30 @@ 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()`: | ||
|
|
||
| ```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. | ||
|
|
@@ -377,3 +401,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 | ||
| 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`); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. private const array WRITE_KEYWORDS = [
'CREATE', 'SET', 'DELETE', 'REMOVE', 'MERGE', 'DROP',
'CALL', 'LOAD', 'FOREACH',
'GRANT', 'DENY', 'REVOKE',
'SHOW',
];So a genuinely read-only MATCH (p:Page) CALL { WITH p MATCH (p)-[:HasSubject]->(s:Subject) RETURN count(s) AS c } RETURN p.name, cSince 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 |
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only the timeout is enforced against the backend;
$transactionConfig = $timeoutSeconds === null
? null
: TransactionConfiguration::default()->withTimeout( (float)$timeoutSeconds );No So against "the raw client runs unbounded queries", an author who ships Suggest splitting the claim: the timeout is enforced; the row cap truncates the returned result, and callers should still put |
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 /**
* @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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
$neoContent = $this->getNeoContent( $revisionRecord );
if ( $neoContent === null ) {
return false;
}with This section's own The same qualifier applies to "Saving a page re-projects that page's nodes" on line 448. |
||
| 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 ) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On a wiki with no graph backend, The broad catch itself looks right: the page is registered unconditionally in } catch ( Exception $e ) {
throw new LuaError( $e->getMessage() );
}optionally catching Worth slightly more than usual here because the docs added in this PR carefully distinguish the two failure types (line 208): "throws a |
||
| $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' ); | ||
| } | ||
|
|
||
| } | ||
| 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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The error branch at The helper already exists: wrapping Minor, since RedHerb is example code — but this PR's stated purpose is keeping the documented call shape working, and |
||
| $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(); | ||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
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:and
:34/:38: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:37links 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.editMainSubjectis the only RedHerb example doing this —createChildandsubjectFinderare clean. Either carve it out by name here, or rework that example onto a supported path.