diff --git a/src/Persistence/Neo4j/Neo4jTypedValue.php b/src/Persistence/Neo4j/Neo4jTypedValue.php new file mode 100644 index 00000000..7e84ea84 --- /dev/null +++ b/src/Persistence/Neo4j/Neo4jTypedValue.php @@ -0,0 +1,28 @@ +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; } diff --git a/src/Persistence/Neo4j/SubjectUpdater.php b/src/Persistence/Neo4j/SubjectUpdater.php index 0243528d..81cc0623 100644 --- a/src/Persistence/Neo4j/SubjectUpdater.php +++ b/src/Persistence/Neo4j/SubjectUpdater.php @@ -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 + ) ); } @@ -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 $nodeProps + * @return array{ array, string, array } + * [ 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}) diff --git a/tests/phpunit/Persistence/Neo4j/Formats/DateTimeFormatNeo4jTest.php b/tests/phpunit/Persistence/Neo4j/Formats/DateTimeFormatNeo4jTest.php index 1476405b..ba94f62c 100644 --- a/tests/phpunit/Persistence/Neo4j/Formats/DateTimeFormatNeo4jTest.php +++ b/tests/phpunit/Persistence/Neo4j/Formats/DateTimeFormatNeo4jTest.php @@ -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; @@ -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 { @@ -36,12 +34,8 @@ 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', ) ), ] ) @@ -49,17 +43,16 @@ public function testStoresAsDateTimes(): void { ) ); $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' ); } diff --git a/tests/phpunit/Persistence/Neo4j/Neo4jValueBuilderRegistryTest.php b/tests/phpunit/Persistence/Neo4j/Neo4jValueBuilderRegistryTest.php index 028988c2..78297da7 100644 --- a/tests/phpunit/Persistence/Neo4j/Neo4jValueBuilderRegistryTest.php +++ b/tests/phpunit/Persistence/Neo4j/Neo4jValueBuilderRegistryTest.php @@ -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; @@ -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' ) + ) + ); + } + } diff --git a/tests/phpunit/Persistence/Neo4j/SubjectUpdaterTest.php b/tests/phpunit/Persistence/Neo4j/SubjectUpdaterTest.php index 20bf65bf..0d6f5942 100644 --- a/tests/phpunit/Persistence/Neo4j/SubjectUpdaterTest.php +++ b/tests/phpunit/Persistence/Neo4j/SubjectUpdaterTest.php @@ -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; @@ -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();