Skip to content
Merged
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
35 changes: 35 additions & 0 deletions DemoData/Page/Cypher.wikitext
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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.

== Query Companies ==

<syntaxhighlight lang="cypher">
MATCH (n:Company) RETURN n.name, n.id
</syntaxhighlight>

{{#cypher_raw: MATCH (n:Company) RETURN n.name, n.id}}

== Query Products ==

<syntaxhighlight lang="cypher">
MATCH (n:Product) RETURN n.name, n.id
</syntaxhighlight>

{{#cypher_raw: MATCH (n:Product) RETURN n.name, n.id}}

== Query Relations ==

<syntaxhighlight lang="cypher">
MATCH (source:Company)-[r]->(target)
RETURN source.name, target.name, r.id
LIMIT 10
</syntaxhighlight>

{{#cypher_raw: MATCH (source:Company)-[r]->(target) RETURN source.name, target.name, r.id LIMIT 10}}

== Notes ==

* Only read-only queries are allowed. Write operations like <code>CREATE</code>, <code>SET</code>, <code>DELETE</code> are rejected.
* Function calls are currently not supported.
* 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]]
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should be Cypher, or Cypher.wikitext should be renamed.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch

I did not realize this came from a commit in this PR so the fix landed in the follow up at

* [[#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 @@ -60,5 +60,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() );
}

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
133 changes: 133 additions & 0 deletions tests/phpunit/EntryPoints/CypherRawParserFunctionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Tests\EntryPoints;

use Exception;
use Laudis\Neo4j\Databags\SummarizedResult;
use Laudis\Neo4j\Types\CypherMap;
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 );

$cypherMaps = array_map(
fn( array $row ) => new CypherMap( $row ),
$returnData
);
$summary = null;
$result = new SummarizedResult( $summary, $cypherMaps );

$queryEngine
->method( 'runReadQuery' )
->willReturn( $result );

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 );
}

}