diff --git a/docs/src/main/asciidoc/modules/ROOT/pages/metadata.adoc b/docs/src/main/asciidoc/modules/ROOT/pages/metadata.adoc index bde01ee54..64197257a 100644 --- a/docs/src/main/asciidoc/modules/ROOT/pages/metadata.adoc +++ b/docs/src/main/asciidoc/modules/ROOT/pages/metadata.adoc @@ -54,3 +54,10 @@ This driver therefore compute node types in a similar way: ** This is in line with the default SQL-to-Cypher translation * Node type combinations will map to table names composed as `label1_label2`, sorting the labels alphabetically to make them independent of the order Neo4j returns them * Property sets for these node type combinations will then be computed + +== Primary keys + +The driver uses available constraint information and will figure out if there is a single, unique constraint on a label. +If that's the case, the constrained property will be assumed to be the primary key. +This will also work for unique constraints over multiple properties, in SQL lingo composite primary keys. +If there is no unique constraint or more than one, we assume the `v$id` virtual columns for the `elementId` value to be primary keys. diff --git a/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/DatabaseMetadataIT.java b/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/DatabaseMetadataIT.java index 70e913ef9..2342d5e28 100644 --- a/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/DatabaseMetadataIT.java +++ b/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/DatabaseMetadataIT.java @@ -18,6 +18,7 @@ */ package org.neo4j.jdbc.it.cp; +import java.io.IOException; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.ResultSet; @@ -32,6 +33,7 @@ import java.util.concurrent.ExecutionException; import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -40,6 +42,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.neo4j.jdbc.Neo4jConnection; +import org.neo4j.jdbc.Neo4jPreparedStatement; import org.testcontainers.junit.jupiter.Testcontainers; import static org.assertj.core.api.Assertions.assertThat; @@ -53,7 +56,7 @@ class DatabaseMetadataIT extends IntegrationTestBase { private Connection connection; DatabaseMetadataIT() { - super("neo4j:5.13.0-enterprise"); + super("neo4j:5.26.0-enterprise"); } @BeforeAll @@ -107,6 +110,28 @@ void indexInfo(String table, boolean unique, List expected) throws SQ assertThat(result).containsExactlyInAnyOrderElementsOf(expected); } + @AfterEach + void dropConstraintsAndIndexes() throws SQLException { + dropConstraint0("SHOW CONSTRAINTS YIELD name", "DROP CONSTRAINT $constraint"); + dropConstraint0("SHOW INDEXES YIELD name", "DROP INDEX $constraint"); + } + + private void dropConstraint0(String getConstraintsStatement, String dropConstraintsStatement) throws SQLException { + this.connection.setAutoCommit(false); + try (var stmt = this.connection.createStatement(); + var results = stmt.executeQuery(getConstraintsStatement); + var stmt2 = this.connection.prepareStatement(dropConstraintsStatement) + .unwrap(Neo4jPreparedStatement.class)) { + + while (results.next()) { + stmt2.setString("constraint", results.getString("name")); + stmt2.addBatch(); + } + stmt2.executeBatch(); + } + this.connection.setAutoCommit(true); + } + @Test void getAllProcedures() throws SQLException { try (var results = this.connection.getMetaData().getProcedures(null, null, null)) { @@ -239,17 +264,17 @@ void testGetUser() throws SQLException { } @Test - void testGetDatabaseProductNameShouldReturnNeo4j() throws SQLException { + void getDatabaseProductNameShouldWork() throws SQLException { var productName = this.connection.getMetaData().getDatabaseProductName(); - assertThat(productName).isEqualTo("Neo4j Kernel-enterprise-5.13.0"); + assertThat(productName).isEqualTo("Neo4j Kernel-enterprise-5.26.0"); } @Test - void getDatabaseProductVersionShouldReturnTestContainerVersion() throws SQLException { - var productName = this.connection.getMetaData().getDatabaseProductVersion(); + void getDatabaseProductVersionShouldWork() throws SQLException { + var productVersion = this.connection.getMetaData().getDatabaseProductVersion(); - assertThat(productName).isEqualTo("5.13.0"); + assertThat(productVersion).isEqualTo("5.26.0"); } @Test @@ -1112,6 +1137,80 @@ void catalogEqualsToDatabaseNameIsOk() { assertThatNoException().isThrownBy(() -> this.connection.getMetaData().getTables("neo4j", null, null, null)); } + @Test + void primaryKeysWithoutUniqueConstraints() throws SQLException, IOException { + + TestUtils.createMovieGraph(this.connection); + + var primaryKeys = this.connection.getMetaData().getPrimaryKeys("neo4j", null, "Movie"); + assertThat(primaryKeys.next()).isTrue(); + assertPrimaryKey(primaryKeys, "Movie", "v$id", 1, "Movie_elementId"); + assertThat(primaryKeys.next()).isFalse(); + + primaryKeys = this.connection.getMetaData().getPrimaryKeys("neo4j", null, "Person_ACTED_IN_Movie"); + assertThat(primaryKeys.next()).isTrue(); + assertPrimaryKey(primaryKeys, "Person_ACTED_IN_Movie", "v$id", 1, "Person_ACTED_IN_Movie_elementId"); + assertThat(primaryKeys.next()).isFalse(); + } + + @Test + void primaryKeysForNonExistingTable() throws SQLException { + var primaryKeys = this.connection.getMetaData().getPrimaryKeys("neo4j", null, "Foobar"); + assertThat(primaryKeys.next()).isFalse(); + } + + @Test + void primaryKeysWithUniqueConstraints() throws SQLException, IOException { + + TestUtils.createMovieGraph(this.connection); + + try (var stmt = this.connection.createStatement()) { + stmt.execute("CREATE CONSTRAINT movie_title FOR (n:Movie) REQUIRE n.title IS UNIQUE"); + stmt.execute("CREATE CONSTRAINT movie_random_col FOR (n:Movie) REQUIRE n.whatever IS UNIQUE"); + stmt.execute("CREATE CONSTRAINT person_id FOR (n:Person) REQUIRE n.id IS UNIQUE"); + stmt.execute( + "CREATE CONSTRAINT acted_in_id IF NOT EXISTS FOR ()-[r:ACTED_IN]-() REQUIRE r.engagement_id IS UNIQUE"); + } + + var primaryKeys = this.connection.getMetaData().getPrimaryKeys("neo4j", null, "Movie"); + assertThat(primaryKeys.next()).isTrue(); + assertPrimaryKey(primaryKeys, "Movie", "v$id", 1, "Movie_elementId"); + assertThat(primaryKeys.next()).isFalse(); + + primaryKeys = this.connection.getMetaData().getPrimaryKeys("neo4j", null, "Person_ACTED_IN_Movie"); + assertThat(primaryKeys.next()).isTrue(); + assertPrimaryKey(primaryKeys, "Person_ACTED_IN_Movie", "engagement_id", 1, "acted_in_id"); + assertThat(primaryKeys.next()).isFalse(); + } + + private static void assertPrimaryKey(ResultSet primaryKeys, String tableName, String columnName, int seq, + String name) throws SQLException { + assertThat(primaryKeys.getString("TABLE_SCHEM")).isEqualTo("public"); + assertThat(primaryKeys.getString("TABLE_CATALOG")).isEqualTo("neo4j"); + assertThat(primaryKeys.getString("TABLE_NAME")).isEqualTo(tableName); + assertThat(primaryKeys.getString("COLUMN_NAME")).isEqualTo(columnName); + assertThat(primaryKeys.getInt("KEY_SEQ")).isEqualTo(seq); + assertThat(primaryKeys.getString("PK_NAME")).isEqualTo(name); + } + + @Test + void primaryKeysWithMoreThanOneColumn() throws SQLException, IOException { + + TestUtils.createMovieGraph(this.connection); + + try (var stmt = this.connection.createStatement()) { + stmt.execute( + "CREATE CONSTRAINT movie_title_per_year FOR (n:Movie) REQUIRE (n.title, n.released) IS UNIQUE"); + } + + var primaryKeys = this.connection.getMetaData().getPrimaryKeys("neo4j", null, "Movie"); + assertThat(primaryKeys.next()).isTrue(); + assertPrimaryKey(primaryKeys, "Movie", "title", 1, "movie_title_per_year"); + assertThat(primaryKeys.next()).isTrue(); + assertPrimaryKey(primaryKeys, "Movie", "released", 2, "movie_title_per_year"); + assertThat(primaryKeys.next()).isFalse(); + } + record IndexInfo(String tableName, boolean nonUnique, String indexName, int type, int ordinalPosition, String columnName, String ascOrDesc) { IndexInfo(ResultSet resultset) throws SQLException { diff --git a/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/TestUtils.java b/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/TestUtils.java index 32205c283..1eae9adb6 100644 --- a/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/TestUtils.java +++ b/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/TestUtils.java @@ -18,9 +18,13 @@ */ package org.neo4j.jdbc.it.cp; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; +import java.util.Objects; import java.util.Optional; import java.util.Properties; @@ -60,6 +64,23 @@ static Connection getConnection(Neo4jContainer neo4j) throws SQLException { return getConnection(neo4j, false); } + static void createMovieGraph(Connection connection) throws SQLException, IOException { + try (var stmt = connection.createStatement(); + var reader = new BufferedReader(new InputStreamReader( + Objects.requireNonNull(TestUtils.class.getResourceAsStream("/movies.cypher"))))) { + var sb = new StringBuilder(); + var buffer = new char[2048]; + var l = 0; + while ((l = reader.read(buffer, 0, buffer.length)) > 0) { + sb.append(buffer, 0, l); + } + var statements = sb.toString().split(";"); + for (String statement : statements) { + stmt.execute("/*+ NEO4J FORCE_CYPHER */ " + statement); + } + } + } + static Connection getConnection(Neo4jContainer neo4j, boolean translate) throws SQLException { var url = "jdbc:neo4j://%s:%d".formatted(neo4j.getHost(), neo4j.getMappedPort(7687)); var driver = DriverManager.getDriver(url); diff --git a/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/TranslationIT.java b/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/TranslationIT.java index b32680da4..9bc22ca56 100644 --- a/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/TranslationIT.java +++ b/neo4j-jdbc-it/neo4j-jdbc-it-cp/src/test/java/org/neo4j/jdbc/it/cp/TranslationIT.java @@ -18,14 +18,11 @@ */ package org.neo4j.jdbc.it.cp; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; import java.sql.DriverManager; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.Properties; import org.jooq.impl.ParserException; @@ -197,20 +194,7 @@ record MovieAndRole(String title, List roles) { void joins() throws IOException, SQLException { try (var connection = getConnection(true, false)) { - try (var stmt = connection.createStatement(); - var reader = new BufferedReader(new InputStreamReader( - Objects.requireNonNull(TranslationIT.class.getResourceAsStream("/movies.cypher"))))) { - var sb = new StringBuilder(); - var buffer = new char[2048]; - var l = 0; - while ((l = reader.read(buffer, 0, buffer.length)) > 0) { - sb.append(buffer, 0, l); - } - var statements = sb.toString().split(";"); - for (String statement : statements) { - stmt.execute("/*+ NEO4J FORCE_CYPHER */ " + statement); - } - } + TestUtils.createMovieGraph(connection); try (var statement = connection.createStatement(); var rs = statement.executeQuery(""" SELECT name AS name, title AS title FROM Person p JOIN Movie m on (m = p.ACTED_IN) diff --git a/neo4j-jdbc/src/main/java/org/neo4j/jdbc/DatabaseMetadataImpl.java b/neo4j-jdbc/src/main/java/org/neo4j/jdbc/DatabaseMetadataImpl.java index c310a3803..016ef0efe 100644 --- a/neo4j-jdbc/src/main/java/org/neo4j/jdbc/DatabaseMetadataImpl.java +++ b/neo4j-jdbc/src/main/java/org/neo4j/jdbc/DatabaseMetadataImpl.java @@ -1104,18 +1104,76 @@ public ResultSet getPrimaryKeys(String catalog, String schema, String table) thr assertSchemaIsPublicOrNull(schema); var keys = new ArrayList(); - keys.add("TABLE_SCHEM"); keys.add("TABLE_CATALOG"); + keys.add("TABLE_SCHEM"); keys.add("TABLE_NAME"); keys.add("COLUMN_NAME"); keys.add("KEY_SEQ"); keys.add("PK_NAME"); + List resultRows = List.of(); + + if (table != null) { + var finalTable = table; + boolean relationshipChecked = false; + // Check if it is a virtual table and extract the relationship name + if (table.matches(".+?_.+?_.+?")) { + var relationships = getTables(catalog, schema, table, new String[] { "RELATIONSHIP" }); + if (relationships.next()) { + relationshipChecked = true; + finalTable = relationships.getString("REMARKS").split("\n")[1].trim(); + } + relationships.close(); + } - var emptyPullResponse = createEmptyPullResponse(); + var request = getRequest("getPrimaryKeys", "name", finalTable); + var pullResponse = doQueryForPullResponse(request); + var records = pullResponse.records(); + + var uniqueConstraints = new ArrayList(); + for (var record : records) { + uniqueConstraints.add(new UniqueConstraint(record.get("name").asString(), + record.get("labelsOrTypes").asList(Value::asString), + record.get("properties").asList(Value::asString))); + } + + // Exactly one unique constraint is fine + if (uniqueConstraints.size() == 1) { + resultRows = makeUniqueKeyValues(getSingleCatalog(), "public", table, uniqueConstraints); + } + // Otherwise we go with element ids if the "table" exists + else { + var exists = relationshipChecked; + if (!exists) { + var tables = getTables(catalog, schema, table, new String[] { "TABLE" }); + exists = tables.next(); + tables.close(); + } + + resultRows = exists + ? makeUniqueKeyValues(getSingleCatalog(), "public", table, + List.of(new UniqueConstraint(table + "_elementId", List.of(table), List.of("v$id")))) + : List.of(); + + } + } + + var pull = staticPullResponseFor(keys, resultRows); var runResponse = createRunResponseForStaticKeys(keys); + return new ResultSetImpl(new LocalStatementImpl(), new ThrowingTransactionImpl(), runResponse, pull, -1, -1, + -1); + } - return new ResultSetImpl(new LocalStatementImpl(), new ThrowingTransactionImpl(), runResponse, - emptyPullResponse, -1, -1, -1); + private List makeUniqueKeyValues(String catalog, String schema, String table, + List uniqueConstraints) { + List results = new ArrayList<>(); + for (var uniqueConstraint : uniqueConstraints) { + for (var i = 0; i < uniqueConstraint.properties.size(); i++) { + results.add(new Value[] { Values.value(catalog), Values.value(schema), Values.value(table), + Values.value(uniqueConstraint.properties.get(i)), Values.value(i + 1), + Values.value(uniqueConstraint.name) }); + } + } + return results; } @Override @@ -1562,4 +1620,7 @@ private record Request(String query, Map args) { private record QueryAndRunResponse(PullResponse pullResponse, CompletableFuture runFuture) { } + private record UniqueConstraint(String name, List labelsOrTypes, List properties) { + } + } diff --git a/neo4j-jdbc/src/main/resources/queries/DatabaseMetadata.properties b/neo4j-jdbc/src/main/resources/queries/DatabaseMetadata.properties index 9a0640e44..40d337a22 100644 --- a/neo4j-jdbc/src/main/resources/queries/DatabaseMetadata.properties +++ b/neo4j-jdbc/src/main/resources/queries/DatabaseMetadata.properties @@ -211,3 +211,7 @@ getUserName=SHOW CURRENT USER YIELD user getMaxConnections=SHOW SETTINGS YIELD * \ WHERE name =~ 'server.bolt.thread_pool_max_size' \ RETURN toInteger(value) + +getPrimaryKeys=SHOW CONSTRAINTS YIELD name, labelsOrTypes, properties \ + WHERE ANY (v IN labelsOrTypes WHERE v = $name) \ + RETURN * diff --git a/neo4j-jdbc/src/test/java/org/neo4j/jdbc/DatabaseMetadataImplTests.java b/neo4j-jdbc/src/test/java/org/neo4j/jdbc/DatabaseMetadataImplTests.java index 3af551210..c0117e771 100644 --- a/neo4j-jdbc/src/test/java/org/neo4j/jdbc/DatabaseMetadataImplTests.java +++ b/neo4j-jdbc/src/test/java/org/neo4j/jdbc/DatabaseMetadataImplTests.java @@ -113,14 +113,6 @@ void getTableTypes() throws SQLException { assertThat(tableTypes).containsExactlyInAnyOrder("TABLE", "RELATIONSHIP"); } - @Test - void getPrimaryKeysShouldReturnEmptyResultSet() throws SQLException { - var databaseMetadata = newDatabaseMetadata(); - try (var tableTypes = databaseMetadata.getPrimaryKeys(null, "public", "someTableDoesNotMatter")) { - assertThat(tableTypes.next()).isFalse(); - } - } - @Test void getPrimaryKeysShouldErrorWhenNonPublicSchemaPassed() { var databaseMetadata = newDatabaseMetadata();