Skip to content
Closed
26 changes: 26 additions & 0 deletions DemoData/Page/Cypher.wikitext
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
This page demonstrates the <code>cypher_raw</code> parser function for executing Cypher queries against the Neo4j graph database.

This is a '''demo feature''' for the NeoWiki proof of concept demo. It will not be present as-is in a production version.

== Basic Query Example ==

Query all companies in the database:

{{#cypher_raw: MATCH (n:Company) RETURN n.label, n.id LIMIT 5}}

== Query with Filtering ==

Find companies founded after 2018:

{{#cypher_raw: MATCH (n:Company) WHERE n.founded_at > 2018 RETURN n.label, n.founded_at}}

== Query with Relations ==

Find companies and their products:

{{#cypher_raw: MATCH (company:Company)-[:Has_product]->(product:Product) RETURN company.label, product.label LIMIT 10}}

== Notes ==

* Only read-only queries are allowed. Write operations like <code>CREATE</code>, <code>SET</code>, <code>DELETE</code> are rejected.
* Query results are displayed as formatted JSON.
1 change: 1 addition & 0 deletions DemoData/Page/Main_Page.wikitext
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Terminology is explained in [https://github.com/ProfessionalWiki/NeoWiki/blob/ma
'''As a developer:'''

* View Subject JSON: [[Special:NeoJson/ACME_Inc]] (developer UI, normal users will not see JSON. [https://github.com/ProfessionalWiki/NeoWiki/blob/master/docs/SubjectFormat.md View docs])
* Query the graph database: [[Cypher raw example]]
* [[#REST_API_endpoints|Explore the REST API]]

== Demo pages ==
Expand Down
5 changes: 4 additions & 1 deletion extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"Hooks": {
"MediaWikiServices": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onMediaWikiServices",

"ParserFirstCallInit": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onParserFirstCallInit",

"RevisionFromEditComplete": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onRevisionFromEditComplete",
"PageDeleteComplete": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onPageDeleteComplete",
"RevisionUndeleted": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onRevisionUndeleted",
Expand Down Expand Up @@ -101,7 +103,8 @@

"ExtensionMessagesFiles": {
"NeoWikiAliases": "i18n/_Aliases.php",
"NeoWikiNamespaces": "i18n/_Namespaces.php"
"NeoWikiNamespaces": "i18n/_Namespaces.php",
"NeoWikiMagic": "i18n/_Magic.php"
},

"RestRoutes": [
Expand Down
7 changes: 7 additions & 0 deletions i18n/_Magic.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

$magicWords = [];

$magicWords['en'] = [
'cypher_raw' => [ 0, 'cypher_raw' ],
];
7 changes: 6 additions & 1 deletion i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,10 @@
"neowiki-subject-creator-button-label": "Create subject",
"neowiki-subject-creator-schema-title": "Have an existing schema?",
"neowiki-subject-creator-existing-schema": "Use existing",
"neowiki-subject-creator-new-schema": "Create new"
"neowiki-subject-creator-new-schema": "Create new",

"neowiki-cypher-raw-error-empty-query": "Empty Cypher query provided",
"neowiki-cypher-raw-error-write-query": "Write queries are not allowed",
"neowiki-cypher-raw-error-query-failed": "Query execution failed: $1",
"neowiki-cypher-raw-error-json-encode": "Failed to encode query result as JSON"
}
6 changes: 5 additions & 1 deletion i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@
},
"neowiki-name": "{{Name}}",
"neowiki-description": "{{Desc|name=NeoWiki|url=https://github.com/ProfessionalWiki/NeoWiki}}",
"neowiki-schema-label": "Label used to identify the schema. $1 is the schema name or link."
"neowiki-schema-label": "Label used to identify the schema. $1 is the schema name or link.",
"neowiki-cypher-raw-error-empty-query": "Error message shown when the cypher_raw parser function is called with an empty query.",
"neowiki-cypher-raw-error-write-query": "Error message shown when a write query is rejected by the cypher_raw parser function.",
"neowiki-cypher-raw-error-query-failed": "Error message shown when Cypher query execution fails. $1 is the exception message.",
"neowiki-cypher-raw-error-json-encode": "Error message shown when JSON encoding of query results fails."
}
54 changes: 54 additions & 0 deletions src/EntryPoints/CypherRawParserFunction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\EntryPoints;

use Exception;
use MediaWiki\Parser\Parser;
use ProfessionalWiki\NeoWiki\CypherQueryFilter;
use ProfessionalWiki\NeoWiki\Persistence\QueryEngine;
use RuntimeException;

class CypherRawParserFunction {

public function __construct(
private readonly QueryEngine $queryEngine,
private readonly CypherQueryFilter $queryFilter
) {
}

public function handle( Parser $parser, string $cypherQuery ): string {
$cypherQuery = trim( $cypherQuery );

if ( $cypherQuery === '' ) {
return $this->formatError( wfMessage( 'neowiki-cypher-raw-error-empty-query' )->text() );
}

if ( !$this->queryFilter->isReadQuery( $cypherQuery ) ) {
return $this->formatError( wfMessage( 'neowiki-cypher-raw-error-write-query' )->text() );
}

try {
$result = $this->queryEngine->runReadQuery( $cypherQuery );
$jsonOutput = json_encode( $result->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );

if ( $jsonOutput === false ) {
throw new RuntimeException( wfMessage( 'neowiki-cypher-raw-error-json-encode' )->text() );
}
Copy link
Member

Choose a reason for hiding this comment

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

I checked, and seems reasonable to actually check for failed json decode.


return $this->formatCodeBlock( $jsonOutput );
} catch ( Exception $e ) {
return $this->formatError( wfMessage( 'neowiki-cypher-raw-error-query-failed', $e->getMessage() )->text() );
}
}

private function formatCodeBlock( string $content ): string {
return '<pre><code class="json">' . "\n" . htmlspecialchars( $content ) . "\n" . '</code></pre>';
}

private function formatError( string $message ): string {
return '<div class="error">' . htmlspecialchars( $message ) . '</div>';
}

}
15 changes: 15 additions & 0 deletions src/EntryPoints/NeoWikiHooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
use MediaWiki\MediaWikiServices;
use MediaWiki\Output\OutputPage;
use MediaWiki\Page\ProperPageIdentity;
use MediaWiki\Parser\Parser;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRoleRegistry;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use ProfessionalWiki\NeoWiki\CypherQueryFilter;
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName;
use ProfessionalWiki\NeoWiki\EntryPoints\Content\SchemaContent;
use ProfessionalWiki\NeoWiki\EntryPoints\Content\SubjectContent;
Expand Down Expand Up @@ -123,6 +125,19 @@ static function ( SlotRoleRegistry $registry ): void {
);
}

public static function onParserFirstCallInit( Parser $parser ): void {
$parser->setFunctionHook(
'cypher_raw',
static function ( Parser $parser, string $cypherQuery ): string {
$parserFunction = new CypherRawParserFunction(
NeoWikiExtension::getInstance()->getQueryStore(),
new CypherQueryFilter()
);
return $parserFunction->handle( $parser, $cypherQuery );
}
);
}

/**
* @see RevisionFromEditCompleteHook
*/
Expand Down
132 changes: 132 additions & 0 deletions tests/phpunit/EntryPoints/CypherRawParserFunctionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Tests\EntryPoints;

use Exception;
use Laudis\Neo4j\Databags\SummarizedResult;
use MediaWiki\Parser\Parser;
use PHPUnit\Framework\TestCase;
use ProfessionalWiki\NeoWiki\CypherQueryFilter;
use ProfessionalWiki\NeoWiki\EntryPoints\CypherRawParserFunction;
use ProfessionalWiki\NeoWiki\Persistence\QueryEngine;

/**
* @covers \ProfessionalWiki\NeoWiki\EntryPoints\CypherRawParserFunction
*/
class CypherRawParserFunctionTest extends TestCase {

private function createMockParser(): Parser {
return $this->createMock( Parser::class );
}

private function createDummyQueryEngine(): QueryEngine {
// Create a simple mock that won't be called
return $this->createMock( QueryEngine::class );
}

private function createQueryEngineWithData( array $returnData ): QueryEngine {
$queryEngine = $this->createMock( QueryEngine::class );
$queryEngine
->method( 'runReadQuery' )
->willReturnCallback( function() use ( $returnData ) {
// We need to return something that has a toArray() method
// Since SummarizedResult is final, we use an anonymous class
return new class( $returnData ) {
public function __construct( private array $data ) {}
public function toArray(): array {
return $this->data;
}
};
} );
return $queryEngine;
}

private function createQueryEngineWithException( Exception $exception ): QueryEngine {
$queryEngine = $this->createMock( QueryEngine::class );
$queryEngine
->method( 'runReadQuery' )
->willThrowException( $exception );
return $queryEngine;
}

public function testEmptyQueryReturnsError(): void {
$parserFunction = new CypherRawParserFunction(
$this->createDummyQueryEngine(),
new CypherQueryFilter()
);

$result = $parserFunction->handle( $this->createMockParser(), '' );

$this->assertStringContainsString( 'error', $result );
}

public function testWriteQueryIsRejected(): void {
$parserFunction = new CypherRawParserFunction(
$this->createDummyQueryEngine(),
new CypherQueryFilter()
);

$result = $parserFunction->handle( $this->createMockParser(), "CREATE (n:Person {name: 'Alice'})" );

$this->assertStringContainsString( 'error', $result );
}

public function testValidReadQueryReturnsFormattedResult(): void {
$testData = [
[ 'name' => 'Alice', 'age' => 30 ],
[ 'name' => 'Bob', 'age' => 25 ]
];

$parserFunction = new CypherRawParserFunction(
$this->createQueryEngineWithData( $testData ),
new CypherQueryFilter()
);

$result = $parserFunction->handle( $this->createMockParser(), 'MATCH (n:Person) RETURN n' );

$this->assertStringContainsString( '<pre><code class="json">', $result );
$this->assertStringContainsString( 'Alice', $result );
$this->assertStringContainsString( 'Bob', $result );
}

public function testQueryExecutionExceptionReturnsError(): void {
$parserFunction = new CypherRawParserFunction(
$this->createQueryEngineWithException( new Exception( 'Connection failed' ) ),
new CypherQueryFilter()
);

$result = $parserFunction->handle( $this->createMockParser(), 'MATCH (n) RETURN n' );

$this->assertStringContainsString( 'error', $result );
}

public function testTrimWhitespaceFromQuery(): void {
$parserFunction = new CypherRawParserFunction(
$this->createQueryEngineWithData( [] ),
new CypherQueryFilter()
);

$result = $parserFunction->handle( $this->createMockParser(), ' MATCH (n) RETURN n ' );

$this->assertStringContainsString( '<pre><code class="json">', $result );
}

public function testOutputIsHTMLEscaped(): void {
$testData = [
[ 'name' => '<script>alert("xss")</script>' ]
];

$parserFunction = new CypherRawParserFunction(
$this->createQueryEngineWithData( $testData ),
new CypherQueryFilter()
);

$result = $parserFunction->handle( $this->createMockParser(), 'MATCH (n) RETURN n' );

$this->assertStringNotContainsString( '<script>alert', $result );
$this->assertStringContainsString( '&lt;script&gt;', $result );
}

}
Loading