Skip to content
Draft
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
28 changes: 28 additions & 0 deletions src/Persistence/Neo4j/Neo4jTypedValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Persistence\Neo4j;

/**
* Marks a value that needs to be wrapped in a Cypher constructor function such
* as `datetime()`, `date()`, `point()`, or `duration()` rather than written as
* a plain scalar. Returned by builders registered with
* {@see Neo4jValueBuilderRegistry} when a property's storage requires a typed
* Neo4j property (so it can be queried with Cypher's temporal/spatial
* functions).
*/
readonly class Neo4jTypedValue {

/**
* @param string $cypherFunction Cypher constructor function name, e.g. 'datetime'.
* @param mixed $value Raw scalar or list of scalars passed as a Cypher
* parameter and wrapped in the constructor.
*/
public function __construct(
public string $cypherFunction,
public mixed $value,
) {
}

}
5 changes: 4 additions & 1 deletion src/Persistence/Neo4j/Neo4jValueBuilderRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ public static function withCoreBuilders(): self {
$registry->registerBuilder( 'url', $toScalars );
$registry->registerBuilder( 'number', $toScalars );
$registry->registerBuilder( 'select', $toScalars );
$registry->registerBuilder( 'dateTime', $toScalars );
$registry->registerBuilder(
'dateTime',
static fn( NeoValue $value ): Neo4jTypedValue => new Neo4jTypedValue( 'datetime', $value->toScalars() )
);

return $registry;
}
Expand Down
63 changes: 52 additions & 11 deletions src/Persistence/Neo4j/SubjectUpdater.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,30 @@ public function updateSubject( Subject $subject, bool $isMainSubject ): void {
}

private function updateNodeProperties( Subject $subject ): void {
$nodeProps = $this->statementsToNodeProperties( $subject->getStatements() );
[ $plainProps, $typedSetClauses, $typedParams ] = $this->extractTypedValues( $nodeProps );

$cypher = 'MERGE (n {id: $id}) SET n = $props';

if ( $typedSetClauses !== '' ) {
$cypher .= ', ' . $typedSetClauses;
}

$this->transaction->run(
'MERGE (n {id: $id}) SET n = $props',
[
'id' => $subject->id->text,
'props' => array_merge(
$this->statementsToNodeProperties( $subject->getStatements() ),
[
'name' => $subject->label->text,
'id' => $subject->id->text,
]
),
]
$cypher,
array_merge(
[
'id' => $subject->id->text,
'props' => array_merge(
$plainProps,
[
'name' => $subject->label->text,
'id' => $subject->id->text,
]
),
],
$typedParams
)
);
}

Expand All @@ -81,6 +93,35 @@ public function statementsToNodeProperties( StatementList $statements ): array {
return $nodeProps;
}

/**
* Splits node properties into plain values (settable via `SET n = $props`)
* and typed values that need a Cypher constructor function.
*
* @param array<string, mixed> $nodeProps
* @return array{ array<string, mixed>, string, array<string, mixed> }
* [ plainProps, typedSetClauses, typedParams ]
*/
private function extractTypedValues( array $nodeProps ): array {
$plainProps = [];
$setClauses = [];
$params = [];

foreach ( $nodeProps as $key => $value ) {
if ( !( $value instanceof Neo4jTypedValue ) ) {
$plainProps[$key] = $value;
continue;
}

$paramName = 'typed_' . count( $params );
$escapedKey = Cypher::escape( $key );

$setClauses[] = "n.$escapedKey = [v IN \$$paramName | {$value->cypherFunction}(v)]";
$params[$paramName] = $value->value;
}

return [ $plainProps, implode( ', ', $setClauses ), $params ];
}

private function updateHasSubjectRelation( Subject $subject, bool $isMainSubject ): void {
$this->transaction->run(
'MATCH (page:Page {id: $pageId}), (subject {id: $subjectId})
Expand Down
31 changes: 12 additions & 19 deletions tests/phpunit/Persistence/Neo4j/Formats/DateTimeFormatNeo4jTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace phpunit\Persistence\Neo4j\Formats;

use Laudis\Neo4j\Types\LocalDateTime;
use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyName;
use ProfessionalWiki\NeoWiki\Domain\Statement;
use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList;
Expand All @@ -20,8 +19,7 @@
class DateTimeFormatNeo4jTest extends NeoWikiIntegrationTestCase {

public function setUp(): void {
self::markTestSkipped( 'Format not supported yet' );
//$this->setUpNeo4j();
$this->setUpNeo4j();
}

public function testStoresAsDateTimes(): void {
Expand All @@ -36,30 +34,25 @@ public function testStoresAsDateTimes(): void {
property: new PropertyName( 'MyProperty' ),
propertyType: DateTimeType::NAME,
value: new StringValue(
'2023-09-13T14:22:23.000Z',
'Ignored bad value',
'2150-12-07T13:37:42.000Z',
'2150-12-07T13:37:42.123Z',
'2150-12-07T13:37:61.000Z', // Seconds too high
'2150-12-07', // Still valid
'2023-09-13T14:22:23Z',
'2150-12-07T13:37:42+02:00',
)
),
] )
),
) );

$result = $store->runReadQuery(
"MATCH (n {id: '$subjectId'}) RETURN n.MyProperty"
)->toRecursiveArray()[0];
"MATCH (n {id: '$subjectId'})
RETURN n.MyProperty = [
datetime('2023-09-13T14:22:23Z'),
datetime('2150-12-07T13:37:42+02:00')
] AS isDatetimeList"
);

$this->assertEquals(
[
new LocalDateTime( 1694614943, 0 ),
new LocalDateTime( 5709706662, 0 ),
new LocalDateTime( 5709706662, 123000000 ),
new LocalDateTime( 5709657600, 0 ),
],
$result['n.MyProperty']
$this->assertTrue(
$result->first()->toRecursiveArray()['isDatetimeList'],
'dateTime statement values should be stored as a list of Neo4j datetimes'
);
}

Expand Down
13 changes: 13 additions & 0 deletions tests/phpunit/Persistence/Neo4j/Neo4jValueBuilderRegistryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PHPUnit\Framework\TestCase;
use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue;
use ProfessionalWiki\NeoWiki\Domain\Value\StringValue;
use ProfessionalWiki\NeoWiki\Persistence\Neo4j\Neo4jTypedValue;
use ProfessionalWiki\NeoWiki\Persistence\Neo4j\Neo4jValueBuilderRegistry;
use ProfessionalWiki\NeoWiki\Tests\Data\TestRelation;

Expand Down Expand Up @@ -76,4 +77,16 @@ public function testTextBuilderConvertsToScalars(): void {
);
}

public function testDateTimeBuilderProducesTypedValueWrappedInDatetimeConstructor(): void {
$registry = Neo4jValueBuilderRegistry::withCoreBuilders();

$this->assertEquals(
new Neo4jTypedValue( 'datetime', [ '2024-01-01T12:00:00Z', '2025-06-15T08:30:00+02:00' ] ),
$registry->buildNeo4jValue(
'dateTime',
new StringValue( '2024-01-01T12:00:00Z', '2025-06-15T08:30:00+02:00' )
)
);
}

}
22 changes: 22 additions & 0 deletions tests/phpunit/Persistence/Neo4j/SubjectUpdaterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectLabel;
use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue;
use ProfessionalWiki\NeoWiki\Domain\Value\StringValue;
use ProfessionalWiki\NeoWiki\Persistence\Neo4j\Neo4jTypedValue;
use ProfessionalWiki\NeoWiki\Persistence\Neo4j\Neo4jValueBuilderRegistry;
use ProfessionalWiki\NeoWiki\Persistence\Neo4j\SubjectUpdater;
use ProfessionalWiki\NeoWiki\Tests\Data\TestRelation;
Expand Down Expand Up @@ -101,6 +102,27 @@ public function testSkipsStatementsWithUnknownPropertyType(): void {
);
}

public function testReturnsTypedValueForDateTimeStatements(): void {
$registry = Neo4jValueBuilderRegistry::withCoreBuilders();

$statements = new StatementList( [
TestStatement::build( property: 'P1', value: new StringValue( 'plain' ), propertyType: 'text' ),
TestStatement::build(
property: 'P2',
value: new StringValue( '2024-01-01T12:00:00Z' ),
propertyType: 'dateTime'
),
] );

$this->assertEquals(
[
'P1' => [ 'plain' ],
'P2' => new Neo4jTypedValue( 'datetime', [ '2024-01-01T12:00:00Z' ] ),
],
$this->newSubjectUpdater( $registry )->statementsToNodeProperties( $statements )
);
}

public function testSkipsStatementsWithRelationType(): void {
$registry = Neo4jValueBuilderRegistry::withCoreBuilders();

Expand Down
Loading