Skip to content

Commit

Permalink
feat: Derive primary keys from unique unique constraints.
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-simons committed Dec 16, 2024
1 parent 77e52f4 commit 8b771b9
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 35 deletions.
7 changes: 7 additions & 0 deletions docs/src/main/asciidoc/modules/ROOT/pages/metadata.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -107,6 +110,28 @@ void indexInfo(String table, boolean unique, List<IndexInfo> 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)) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -197,20 +194,7 @@ record MovieAndRole(String title, List<String> 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)
Expand Down
69 changes: 65 additions & 4 deletions neo4j-jdbc/src/main/java/org/neo4j/jdbc/DatabaseMetadataImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -1104,18 +1104,76 @@ public ResultSet getPrimaryKeys(String catalog, String schema, String table) thr
assertSchemaIsPublicOrNull(schema);

var keys = new ArrayList<String>();
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<Value[]> 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<UniqueConstraint>();
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<Value[]> makeUniqueKeyValues(String catalog, String schema, String table,
List<UniqueConstraint> uniqueConstraints) {
List<Value[]> 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
Expand Down Expand Up @@ -1562,4 +1620,7 @@ private record Request(String query, Map<String, Object> args) {
private record QueryAndRunResponse(PullResponse pullResponse, CompletableFuture<RunResponse> runFuture) {
}

private record UniqueConstraint(String name, List<String> labelsOrTypes, List<String> properties) {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit 8b771b9

Please sign in to comment.