Skip to content

Commit 156e43c

Browse files
nord-erikfbiville
authored andcommitted
feat: allow some relation nodes to not define key/unique properties
This commit relaxes validation `NKRN-001`, which, until now, required that every node target, referenced by a relationship, defines at least one key or unique constraint. The validation now allows such node targets to not define any key or unique constraints themselves, if and only if another node covers some of its labels and properties. If the node reference overrides the mappings for key properties, all these properties must be matched by key or unique constraints defined elsewhere for any of the node labels. Signed-off-by: Florent Biville <[email protected]>
1 parent 25db61c commit 156e43c

File tree

12 files changed

+481
-31
lines changed

12 files changed

+481
-31
lines changed

core/src/main/java/org/neo4j/importer/v1/pipeline/ImportExecutionPlan.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import java.util.Objects;
2626
import java.util.Set;
2727
import java.util.stream.Collectors;
28-
import org.neo4j.importer.v1.graph.Graphs;
28+
import org.neo4j.importer.v1.util.graph.Graphs;
2929

3030
/**
3131
* Represents the entire parallelizable execution plan for an import step graph.

core/src/main/java/org/neo4j/importer/v1/pipeline/ImportPipeline.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
import org.neo4j.importer.v1.ImportSpecification;
3737
import org.neo4j.importer.v1.actions.Action;
3838
import org.neo4j.importer.v1.actions.ActionStage;
39-
import org.neo4j.importer.v1.graph.Graphs;
4039
import org.neo4j.importer.v1.sources.Source;
4140
import org.neo4j.importer.v1.targets.CustomQueryTarget;
4241
import org.neo4j.importer.v1.targets.KeyMapping;
@@ -45,6 +44,7 @@
4544
import org.neo4j.importer.v1.targets.RelationshipTarget;
4645
import org.neo4j.importer.v1.targets.Target;
4746
import org.neo4j.importer.v1.targets.Targets;
47+
import org.neo4j.importer.v1.util.graph.Graphs;
4848

4949
/**
5050
* {@link ImportPipeline} exposes a topologically-ordered set of {@link ImportStep},
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright (c) "Neo4j"
3+
* Neo4j Sweden AB [https://neo4j.com]
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.neo4j.importer.v1.util.collections;
18+
19+
import java.util.ArrayList;
20+
import java.util.Comparator;
21+
import java.util.Set;
22+
import java.util.stream.Collectors;
23+
import java.util.stream.IntStream;
24+
import java.util.stream.Stream;
25+
26+
public class Sets {
27+
28+
/**
29+
* Returns all elements of the first set not contained in set2
30+
* @param set1 first set
31+
* @param set2 second set
32+
* @return elements of set1 not in set2
33+
* @param <T> type of elements
34+
*/
35+
public static <T> Set<T> difference(Set<T> set1, Set<T> set2) {
36+
return set1.stream().filter(element -> !set2.contains(element)).collect(Collectors.toSet());
37+
}
38+
39+
/**
40+
* @see Sets#generateNonEmptySubsets(Set, Comparator)
41+
*/
42+
public static <T> Stream<Set<T>> generateNonEmptySubsets(Set<T> inputSet) {
43+
return generateNonEmptySubsets(inputSet, null);
44+
}
45+
46+
/**
47+
* This generates a {@code Stream} of all non-empty subsets of the provided input set.
48+
* <p>
49+
* The method optionally accepts a {@code Comparator<Set<T>>} to sort the resulting
50+
* stream of subsets. If the comparator is {@code null}, the subsets are returned
51+
* in the order they are generated.
52+
* <p>
53+
* This method has an exponential time complexity of O(n * 2^n)**, where n is the size
54+
* of the input set. Please call this sparingly.
55+
*
56+
* @param <T> type of inputs.
57+
* @param inputSet inputs
58+
* @param order optional {@code Comparator<Set<T>>} order to sort subsets with. If {@code null}, no ordering is applied.
59+
* @return {@code Stream<Set<T>>} containing all 2^n - 1 non-empty subsets, optionally ordered.
60+
*/
61+
public static <T> Stream<Set<T>> generateNonEmptySubsets(Set<T> inputSet, Comparator<Set<T>> order) {
62+
var elements = new ArrayList<>(inputSet);
63+
var n = elements.size();
64+
if (n == 0) {
65+
return Stream.empty();
66+
}
67+
68+
Stream<Set<T>> result = IntStream.range(1, 1 << n /* 2^n unordered subsets */)
69+
.mapToObj(subsetIndex -> IntStream.range(0, n)
70+
/*
71+
* The bitwise subset generation method maps each element in the input list
72+
* to a specific bit position (inputIndex) and uses an integer 'subsetIndex' to
73+
* determine which elements to include.
74+
*
75+
* Given inputSet = ["Apple" (inputIndex=0),
76+
* "Banana" (inputIndex=1),
77+
* "Cherry" (inputIndex=2)],
78+
* the generation loop iterates through 'subsetIndex' from 1 to 7 (2^3 - 1).
79+
*
80+
* The filter condition is: {@code (subsetIndex & (1 << inputIndex)) != 0}
81+
*
82+
* ----------------------------------------------------------------------------------------------------------------------------
83+
* | subsetIndex | Binary | Bits Set | inputIndex=0 | inputIndex=1 | inputIndex=2 | Subset
84+
* | --------------------------------------------------------------------------------------------------------------------------
85+
* | 1 | 001 | inputIndex=0 | YES | NO | NO | {"Apple"}
86+
* | 2 | 010 | inputIndex=1 | NO | YES | NO | {"Banana"}
87+
* | 3 | 011 | inputIndex=0, 1 | YES | YES | NO | {"Apple", "Banana"}
88+
* | 4 | 100 | inputIndex=2 | NO | NO | YES | {"Cherry"}
89+
* | 5 | 101 | inputIndex=0, 2 | YES | NO | YES | {"Apple", "Cherry"}
90+
* | 6 | 110 | inputIndex=1, 2 | NO | YES | YES | {"Banana", "Cherry"}
91+
* | 7 | 111 | inputIndex=0, 1, 2 | YES | YES | YES | {"Apple", "Banana", "Cherry"}
92+
* ----------------------------------------------------------------------------------------------------------------------------
93+
*/
94+
.filter(inputIndex -> (subsetIndex & (1 << inputIndex)) != 0)
95+
.mapToObj(elements::get)
96+
.collect(Collectors.toSet()));
97+
98+
if (order != null) {
99+
return result.sorted(order);
100+
}
101+
return result;
102+
}
103+
}

core/src/main/java/org/neo4j/importer/v1/graph/Graphs.java renamed to core/src/main/java/org/neo4j/importer/v1/util/graph/Graphs.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* See the License for the specific language governing permissions and
1515
* limitations under the License.
1616
*/
17-
package org.neo4j.importer.v1.graph;
17+
package org.neo4j.importer.v1.util.graph;
1818

1919
import java.util.ArrayDeque;
2020
import java.util.ArrayList;

core/src/main/java/org/neo4j/importer/v1/graph/Pair.java renamed to core/src/main/java/org/neo4j/importer/v1/util/graph/Pair.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* See the License for the specific language governing permissions and
1515
* limitations under the License.
1616
*/
17-
package org.neo4j.importer.v1.graph;
17+
package org.neo4j.importer.v1.util.graph;
1818

1919
import java.util.Objects;
2020

core/src/main/java/org/neo4j/importer/v1/validation/SpecificationValidators.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import java.util.function.Function;
2828
import java.util.stream.Collectors;
2929
import org.neo4j.importer.v1.ImportSpecification;
30-
import org.neo4j.importer.v1.graph.Graphs;
30+
import org.neo4j.importer.v1.util.graph.Graphs;
3131

3232
public class SpecificationValidators {
3333

core/src/main/java/org/neo4j/importer/v1/validation/plugin/NoDanglingDependsOnValidator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@
2222
import java.util.Map;
2323
import java.util.Set;
2424
import java.util.concurrent.atomic.AtomicBoolean;
25-
import org.neo4j.importer.v1.graph.Pair;
2625
import org.neo4j.importer.v1.targets.CustomQueryTarget;
2726
import org.neo4j.importer.v1.targets.NodeTarget;
2827
import org.neo4j.importer.v1.targets.RelationshipTarget;
2928
import org.neo4j.importer.v1.targets.Target;
29+
import org.neo4j.importer.v1.util.graph.Pair;
3030
import org.neo4j.importer.v1.validation.SpecificationValidationResult.Builder;
3131
import org.neo4j.importer.v1.validation.SpecificationValidator;
3232

core/src/main/java/org/neo4j/importer/v1/validation/plugin/NoDependencyCycleValidator.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@
2626
import java.util.Set;
2727
import java.util.concurrent.atomic.AtomicBoolean;
2828
import java.util.stream.Collectors;
29-
import org.neo4j.importer.v1.graph.Graphs;
30-
import org.neo4j.importer.v1.graph.Pair;
3129
import org.neo4j.importer.v1.targets.CustomQueryTarget;
3230
import org.neo4j.importer.v1.targets.NodeReference;
3331
import org.neo4j.importer.v1.targets.NodeTarget;
3432
import org.neo4j.importer.v1.targets.RelationshipTarget;
3533
import org.neo4j.importer.v1.targets.Target;
34+
import org.neo4j.importer.v1.util.graph.Graphs;
35+
import org.neo4j.importer.v1.util.graph.Pair;
3636
import org.neo4j.importer.v1.validation.SpecificationValidationResult.Builder;
3737
import org.neo4j.importer.v1.validation.SpecificationValidator;
3838

core/src/main/java/org/neo4j/importer/v1/validation/plugin/NoKeylessRelationshipNodeValidator.java

Lines changed: 141 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,31 @@
1616
*/
1717
package org.neo4j.importer.v1.validation.plugin;
1818

19+
import java.util.HashMap;
1920
import java.util.HashSet;
2021
import java.util.LinkedHashMap;
22+
import java.util.List;
2123
import java.util.Map;
24+
import java.util.Objects;
2225
import java.util.Set;
26+
import java.util.stream.Collectors;
27+
import org.neo4j.importer.v1.targets.KeyMapping;
28+
import org.neo4j.importer.v1.targets.NodeReference;
2329
import org.neo4j.importer.v1.targets.NodeTarget;
30+
import org.neo4j.importer.v1.targets.PropertyMapping;
2431
import org.neo4j.importer.v1.targets.RelationshipTarget;
32+
import org.neo4j.importer.v1.util.collections.Sets;
2533
import org.neo4j.importer.v1.validation.SpecificationValidationResult.Builder;
2634
import org.neo4j.importer.v1.validation.SpecificationValidator;
2735

2836
public class NoKeylessRelationshipNodeValidator implements SpecificationValidator {
2937
private static final String ERROR_CODE = "NKRN-001";
3038

31-
private final Set<String> keylessNodes = new HashSet<>();
39+
private final Map<String, NodeShape> visitedNodeShapes = new HashMap<>();
40+
41+
private final Set<String> targetsWithoutConstraint = new HashSet<>();
42+
43+
private final Set<NodePattern> nodePatternsWithConstraints = new HashSet<>();
3244

3345
private final Map<String, String> errorMessages = new LinkedHashMap<>();
3446

@@ -44,28 +56,25 @@ public Set<Class<? extends SpecificationValidator>> requires() {
4456

4557
@Override
4658
public void visitNodeTarget(int index, NodeTarget target) {
59+
visitedNodeShapes.put(target.getName(), new NodeShape(target));
4760
var schema = target.getSchema();
48-
if (schema.getKeyConstraints().isEmpty()
49-
&& schema.getUniqueConstraints().isEmpty()) {
50-
keylessNodes.add(target.getName());
61+
var keyConstraints = schema.getKeyConstraints();
62+
var uniqueConstraints = schema.getUniqueConstraints();
63+
if (keyConstraints.isEmpty() && uniqueConstraints.isEmpty()) {
64+
targetsWithoutConstraint.add(target.getName());
65+
} else {
66+
keyConstraints.forEach(constraint -> nodePatternsWithConstraints.add(
67+
new NodePattern(constraint.getLabel(), new HashSet<>(constraint.getProperties()))));
68+
uniqueConstraints.forEach(constraint -> nodePatternsWithConstraints.add(
69+
new NodePattern(constraint.getLabel(), new HashSet<>(constraint.getProperties()))));
5170
}
5271
}
5372

5473
public void visitRelationshipTarget(int index, RelationshipTarget target) {
55-
var startNode = target.getStartNodeReference().getName();
56-
if (keylessNodes.contains(startNode)) {
57-
errorMessages.put(
58-
String.format("$.targets.relationships[%d].start_node_reference", index),
59-
String.format(
60-
"Node %s must define a key or unique constraint for property id, none found", startNode));
61-
}
62-
var endNode = target.getEndNodeReference().getName();
63-
if (keylessNodes.contains(endNode)) {
64-
errorMessages.put(
65-
String.format("$.targets.relationships[%d].end_node_reference", index),
66-
String.format(
67-
"Node %s must define a key or unique constraint for property id, none found", endNode));
68-
}
74+
checkNode(
75+
String.format("$.targets.relationships[%d].start_node_reference", index),
76+
target.getStartNodeReference());
77+
checkNode(String.format("$.targets.relationships[%d].end_node_reference", index), target.getEndNodeReference());
6978
}
7079

7180
@Override
@@ -75,4 +84,118 @@ public boolean report(Builder builder) {
7584
});
7685
return !errorMessages.isEmpty();
7786
}
87+
88+
private void checkNode(String path, NodeReference nodeReference) {
89+
var nodeName = nodeReference.getName();
90+
if (!targetsWithoutConstraint.contains(nodeName)) {
91+
return;
92+
}
93+
// match call can be costly, only run it for node targets without any key/unique constraints
94+
var keyMappings = nodeReference.getKeyMappings();
95+
var matchResult = visitedNodeShapes.get(nodeName).match(nodePatternsWithConstraints, keyMappings);
96+
if (!matchResult.matches()) {
97+
var unmatchedList = matchResult.unmatchedProperties().stream()
98+
.map(Object::toString)
99+
.collect(Collectors.joining("','", "'", "'"));
100+
errorMessages.put(
101+
path,
102+
String.format(
103+
"Node '%s' must define key or unique constraints for %s the properties (%s)",
104+
nodeName, keyMappings.isEmpty() ? "any of" : "all of", unmatchedList));
105+
}
106+
}
107+
108+
private static final class NodeShape {
109+
private final List<String> labels;
110+
private final List<String> properties;
111+
112+
public NodeShape(NodeTarget target) {
113+
this.labels = target.getLabels();
114+
this.properties = target.getProperties().stream()
115+
.map(PropertyMapping::getTargetProperty)
116+
.collect(Collectors.toList());
117+
}
118+
119+
@Override
120+
public boolean equals(Object o) {
121+
if (!(o instanceof NodeShape)) return false;
122+
NodeShape that = (NodeShape) o;
123+
return Objects.equals(labels, that.labels) && Objects.equals(properties, that.properties);
124+
}
125+
126+
@Override
127+
public int hashCode() {
128+
return Objects.hash(labels, properties);
129+
}
130+
131+
@Override
132+
public String toString() {
133+
return "NodeTargetShape{" + "labels=" + labels + ", properties=" + properties + '}';
134+
}
135+
136+
public PropertyMatchResult match(Set<NodePattern> patterns, List<KeyMapping> keyMappings) {
137+
var allProperties = new HashSet<>(this.properties);
138+
var keys = keyMappings.stream().map(KeyMapping::getNodeProperty).collect(Collectors.toSet());
139+
// if no known key properties, we need to check all the non-empty subsets from the node properties
140+
var coveredProperties = Sets.generateNonEmptySubsets(keys.isEmpty() ? allProperties : keys)
141+
.flatMap(combination -> this.labels.stream().map(label -> new NodePattern(label, combination)))
142+
.filter(patterns::contains)
143+
// all key properties must match a key/unique constraint pattern
144+
// (individually or as part of a larger subset)
145+
.flatMap(pattern -> pattern.properties.stream())
146+
.collect(Collectors.toSet());
147+
if (keys.isEmpty()) {
148+
var success = !coveredProperties.isEmpty();
149+
return new PropertyMatchResult(success, success ? Set.of() : allProperties);
150+
}
151+
var uncoveredKeys = Sets.difference(keys, coveredProperties);
152+
return new PropertyMatchResult(uncoveredKeys.isEmpty(), uncoveredKeys);
153+
}
154+
}
155+
156+
private static final class PropertyMatchResult {
157+
private final boolean matches;
158+
private final Set<String> unmatchedProperties;
159+
160+
public PropertyMatchResult(boolean matches, Set<String> unmatchedProperties) {
161+
this.matches = matches;
162+
this.unmatchedProperties = unmatchedProperties;
163+
}
164+
165+
public boolean matches() {
166+
return matches;
167+
}
168+
169+
public List<String> unmatchedProperties() {
170+
return unmatchedProperties.stream().sorted().collect(Collectors.toList());
171+
}
172+
}
173+
174+
private static final class NodePattern {
175+
176+
private final String label;
177+
private final Set<String> properties;
178+
179+
public NodePattern(String label, Set<String> properties) {
180+
this.label = label;
181+
this.properties = properties;
182+
}
183+
184+
@Override
185+
public boolean equals(Object o) {
186+
if (!(o instanceof NodePattern)) return false;
187+
NodePattern that = (NodePattern) o;
188+
return Objects.equals(label, that.label) && Objects.equals(properties, that.properties);
189+
}
190+
191+
@Override
192+
public int hashCode() {
193+
return Objects.hash(label, properties);
194+
}
195+
196+
@Override
197+
public String toString() {
198+
return "NodePattern{" + "label='" + label + '\'' + ", properties=" + properties + '}';
199+
}
200+
}
78201
}

0 commit comments

Comments
 (0)