Summary
@EnableDrivineTestConfig's built-in Neo4j testcontainer is hardcoded to neo4j:5.26.1-community (DrivineTestContainer.NEO4J_VERSION = "5.26.1", in org.drivine.test.DrivineTestContainer). That version has a confirmed upstream Neo4j bug that affects any app using Cypher's dynamic relationship-type syntax (CREATE (a)-[r:$($relType)]->(b)), which is exactly what DrivineNamedEntityDataRepository's generated merge_named_entity_relationship.cypher / create_named_entity_relationship.cypher use for every relationship write.
The bug
Upstream: neo4j/neo4j#13597 ("Dynamic relationships do not work correctly as the parameters change")
A dynamic relationship-type parameter gets baked into the server's query-plan cache on first execution and is silently reused for every later execution of the same query text, even when a different $relType value is bound — no error, just the wrong relationship type written. Confirmed affected: 5.26.1–5.26.3 and 2025.01.0 (community edition; there's an unconfirmed report that Enterprise Edition is not affected). Confirmed fixed in 2026.05-community (current latest as of this report).
Because mergeRelationship/createRelationship use exactly this dynamic-type pattern and get called once per relationship over the life of a long-running PersistenceManager, any consumer of drivine4j on an affected Neo4j version risks silently mis-typed relationships in production once more than one distinct relationship type has ever been written on a given connection/session.
Minimal repro (plain Neo4j Python driver, no Drivine involved — isolates this to the Neo4j server, not this library)
from neo4j import GraphDatabase
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
stmt = "MERGE (a:Entity {id: $from}) MERGE (b:Entity {id: $to}) CREATE (a)-[r:$($relType)]->(b)"
with driver.session() as s:
s.run("MATCH (n) DETACH DELETE n")
s.run(stmt, {"from": "e1", "to": "e2", "relType": "FIRST_TYPE"})
s.run(stmt, {"from": "e3", "to": "e4", "relType": "SECOND_TYPE"})
print(sorted(r["t"] for r in s.run("MATCH ()-[r]->() RETURN DISTINCT type(r) AS t")))
# actual on 5.26.1-community: ['FIRST_TYPE'] <- SECOND_TYPE silently dropped
# expected / actual on 2026.05-community: ['FIRST_TYPE', 'SECOND_TYPE']
Calling CALL db.clearQueryCaches() between the two writes on the buggy version makes the second write correct — confirming it's the query-plan cache baking in the first-ever-bound value for that literal query text.
What we did downstream
We initially prototyped a Testcontainers ImageNameSubstitutor to intercept and rewrite the neo4j:5.26.1-community image request without touching drivine4j — that works, but it's a bit of a sledgehammer (global JVM-wide image substitution) for what turned out to have a much simpler answer already built into this library:
test.neo4j.use-local / USE_LOCAL_NEO4J=true already does exactly what we need — it tells DrivineTestConfiguration to skip the built-in testcontainer and use whatever's in the Spring Environment for that datasource as-is. So we run our own org.testcontainers.containers.Neo4jContainer (pinned to neo4j:2026.05-community), one per test JVM via a Kotlin object/by lazy singleton, and wire its actual host/port/password in via a @DynamicPropertySource method on each test class:
object Neo4jTestContainer {
const val PASSWORD = "test-password"
val instance: Neo4jContainer<*> by lazy {
Neo4jContainer(DockerImageName.parse("neo4j:2026.05-community").asCompatibleSubstituteFor(DockerImageName.parse("neo4j")))
.withAdminPassword(PASSWORD)
.also { it.start() }
}
fun registerProperties(registry: DynamicPropertyRegistry) {
registry.add("test.neo4j.use-local") { "true" }
registry.add("database.datasources.neo.host") { instance.host }
registry.add("database.datasources.neo.port") { instance.getMappedPort(7687) }
registry.add("database.datasources.neo.password") { PASSWORD }
}
}
// per test class:
companion object {
@JvmStatic
@DynamicPropertySource
fun neo4jProperties(registry: DynamicPropertyRegistry) = Neo4jTestContainer.registerProperties(registry)
}
Verified: with this in place, @EnableDrivineTestConfig's own built-in testcontainer is never started at all (confirmed via logs — only our neo4j:2026.05-community container starts, shared across all IT classes in a module's test JVM), every existing test stays green, and the dynamic-relationship-type repro above passes.
Ask
No library change is strictly required — test.neo4j.use-local already solves this for anyone who needs a different Neo4j version. But it might be worth:
- Bumping the default
NEO4J_VERSION in DrivineTestContainer past the fixed line, so the out-of-the-box experience (no use-local needed) isn't silently exposed to a data-corrupting bug.
- Calling out
test.neo4j.use-local + a custom container in TEST_CONTAINERS.md as the documented path for pinning a specific Neo4j version, since right now it reads primarily as a "point at your already-running local Neo4j" feature rather than "bring your own testcontainer instance too."
Happy to open a PR for either if useful.
Summary
@EnableDrivineTestConfig's built-in Neo4j testcontainer is hardcoded toneo4j:5.26.1-community(DrivineTestContainer.NEO4J_VERSION = "5.26.1", inorg.drivine.test.DrivineTestContainer). That version has a confirmed upstream Neo4j bug that affects any app using Cypher's dynamic relationship-type syntax (CREATE (a)-[r:$($relType)]->(b)), which is exactly whatDrivineNamedEntityDataRepository's generatedmerge_named_entity_relationship.cypher/create_named_entity_relationship.cypheruse for every relationship write.The bug
Upstream: neo4j/neo4j#13597 ("Dynamic relationships do not work correctly as the parameters change")
A dynamic relationship-type parameter gets baked into the server's query-plan cache on first execution and is silently reused for every later execution of the same query text, even when a different
$relTypevalue is bound — no error, just the wrong relationship type written. Confirmed affected:5.26.1–5.26.3and2025.01.0(community edition; there's an unconfirmed report that Enterprise Edition is not affected). Confirmed fixed in2026.05-community(current latest as of this report).Because
mergeRelationship/createRelationshipuse exactly this dynamic-type pattern and get called once per relationship over the life of a long-runningPersistenceManager, any consumer ofdrivine4jon an affected Neo4j version risks silently mis-typed relationships in production once more than one distinct relationship type has ever been written on a given connection/session.Minimal repro (plain Neo4j Python driver, no Drivine involved — isolates this to the Neo4j server, not this library)
Calling
CALL db.clearQueryCaches()between the two writes on the buggy version makes the second write correct — confirming it's the query-plan cache baking in the first-ever-bound value for that literal query text.What we did downstream
We initially prototyped a Testcontainers
ImageNameSubstitutorto intercept and rewrite theneo4j:5.26.1-communityimage request without touching drivine4j — that works, but it's a bit of a sledgehammer (global JVM-wide image substitution) for what turned out to have a much simpler answer already built into this library:test.neo4j.use-local/USE_LOCAL_NEO4J=truealready does exactly what we need — it tellsDrivineTestConfigurationto skip the built-in testcontainer and use whatever's in the SpringEnvironmentfor that datasource as-is. So we run our ownorg.testcontainers.containers.Neo4jContainer(pinned toneo4j:2026.05-community), one per test JVM via a Kotlinobject/by lazysingleton, and wire its actual host/port/password in via a@DynamicPropertySourcemethod on each test class:Verified: with this in place,
@EnableDrivineTestConfig's own built-in testcontainer is never started at all (confirmed via logs — only ourneo4j:2026.05-communitycontainer starts, shared across all IT classes in a module's test JVM), every existing test stays green, and the dynamic-relationship-type repro above passes.Ask
No library change is strictly required —
test.neo4j.use-localalready solves this for anyone who needs a different Neo4j version. But it might be worth:NEO4J_VERSIONinDrivineTestContainerpast the fixed line, so the out-of-the-box experience (nouse-localneeded) isn't silently exposed to a data-corrupting bug.test.neo4j.use-local+ a custom container inTEST_CONTAINERS.mdas the documented path for pinning a specific Neo4j version, since right now it reads primarily as a "point at your already-running local Neo4j" feature rather than "bring your own testcontainer instance too."Happy to open a PR for either if useful.