diff --git a/docs/extending/extending.md b/docs/extending/extending.md index 692ad4110..7178e7c59 100644 --- a/docs/extending/extending.md +++ b/docs/extending/extending.md @@ -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 { @@ -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. @@ -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 +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. diff --git a/phpcs.xml b/phpcs.xml index ef81a3ef1..4806ecede 100755 --- a/phpcs.xml +++ b/phpcs.xml @@ -3,6 +3,8 @@ src/ tests/ + tests/RedHerb/node_modules/ + diff --git a/tests/RedHerb/README.md b/tests/RedHerb/README.md index 0bf456fda..f66bb54ef 100644 --- a/tests/RedHerb/README.md +++ b/tests/RedHerb/README.md @@ -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) diff --git a/tests/RedHerb/extension.json b/tests/RedHerb/extension.json index 380838db0..b99275852 100644 --- a/tests/RedHerb/extension.json +++ b/tests/RedHerb/extension.json @@ -47,6 +47,9 @@ "SpecialPages": { "RedHerbSubjectFinder": { "class": "ProfessionalWiki\\RedHerb\\Specials\\SpecialRedHerbSubjectFinder" + }, + "RedHerbContentPageCount": { + "class": "ProfessionalWiki\\RedHerb\\Specials\\SpecialRedHerbContentPageCount" } }, diff --git a/tests/RedHerb/i18n/_Aliases.php b/tests/RedHerb/i18n/_Aliases.php index 5c8563a32..a5e299398 100644 --- a/tests/RedHerb/i18n/_Aliases.php +++ b/tests/RedHerb/i18n/_Aliases.php @@ -4,4 +4,5 @@ $specialPageAliases['en'] = [ 'RedHerbSubjectFinder' => [ 'RedHerbSubjectFinder' ], + 'RedHerbContentPageCount' => [ 'RedHerbContentPageCount' ], ]; diff --git a/tests/RedHerb/i18n/en.json b/tests/RedHerb/i18n/en.json index 669f2d0c7..c0f270730 100644 --- a/tests/RedHerb/i18n/en.json +++ b/tests/RedHerb/i18n/en.json @@ -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", diff --git a/tests/RedHerb/i18n/qqq.json b/tests/RedHerb/i18n/qqq.json index 728c2d84a..11c066d05 100644 --- a/tests/RedHerb/i18n/qqq.json +++ b/tests/RedHerb/i18n/qqq.json @@ -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.", diff --git a/tests/RedHerb/resources/editMainSubject/init.js b/tests/RedHerb/resources/editMainSubject/init.js index d9bf0ce77..cdd14ec69 100644 --- a/tests/RedHerb/resources/editMainSubject/init.js +++ b/tests/RedHerb/resources/editMainSubject/init.js @@ -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; @@ -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(), @@ -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 ); } ); diff --git a/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php b/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php new file mode 100644 index 000000000..57c0b9820 --- /dev/null +++ b/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php @@ -0,0 +1,46 @@ +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' ); + } + +} diff --git a/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php b/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php new file mode 100644 index 000000000..abfdaf8fc --- /dev/null +++ b/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php @@ -0,0 +1,60 @@ +setUpNeo4j(); + $this->createSchema( TestSubject::DEFAULT_SCHEMA_ID ); + $this->markPageTableAsUsed(); + } + + public function testCountsOnlyContentNamespacePagesTrackedInTheGraph(): void { + $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(); + } + +}