Skip to content

Commit 92e887d

Browse files
Merge pull request #400 from QRun-IO/feature/join-enhancements
Feature/join enhancements
2 parents 95e34ee + b73e9b9 commit 92e887d

File tree

8 files changed

+2594
-79
lines changed

8 files changed

+2594
-79
lines changed

qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/metadata/JoinGraph.java

Lines changed: 138 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,61 @@
2626
import java.util.Comparator;
2727
import java.util.HashSet;
2828
import java.util.List;
29-
import java.util.Objects;
3029
import java.util.Set;
3130
import java.util.TreeSet;
3231
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
32+
import com.kingsrook.qqq.backend.core.logging.QLogger;
3333
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
3434
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
3535
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
36+
import com.kingsrook.qqq.backend.core.utils.ListingHash;
3637
import com.kingsrook.qqq.backend.core.utils.StringUtils;
38+
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
3739

3840

3941
/*******************************************************************************
40-
** Object to represent the graph of joins in a QQQ Instance. e.g., all of the
41-
** connections among tables through joins.
42+
** Represents the graph of table-to-table joins in a QQQ Instance, treating
43+
** each join as a non-directional edge between two tables.
44+
**
45+
** <p>The primary purpose of this class is to answer the question: "given a
46+
** starting table, what other tables can be reached through joins, and via
47+
** which paths?" This is used during instance enrichment and validation to
48+
** discover multi-hop join paths (e.g., order → orderLine → item).</p>
49+
**
50+
** <p>Key behaviors:</p>
51+
** <ul>
52+
** <li><b>Deduplication:</b> If the instance defines both A → B and B → A
53+
** joins (on the same fields), they are normalized into a single edge
54+
** so the graph does not contain redundant paths.</li>
55+
** <li><b>Flipped-join awareness:</b> Even though duplicate joins are
56+
** collapsed into one edge, the {@code flippedJoins} map remembers all
57+
** original join names for each table pair, so that
58+
** {@link JoinConnectionList#matchesJoinPath(List, JoinGraph, QInstance)}
59+
** can match a path by any equivalent join name, not just the one
60+
** stored in the edge.</li>
61+
** <li><b>Path-length limiting:</b> To keep traversal performant on large
62+
** instances, paths longer than {@code maxPathLength} (default 3) are
63+
** pruned. This limit is configurable via the system property
64+
** {@code qqq.instance.joinGraph.maxPathLength} or environment variable
65+
** {@code QQQ_INSTANCE_JOIN_GRAPH_MAX_PATH_LENGTH}.</li>
66+
** </ul>
4267
*******************************************************************************/
4368
public class JoinGraph
4469
{
70+
private static final QLogger LOG = QLogger.getLogger(JoinGraph.class);
71+
4572
private Set<Edge> edges = new HashSet<>();
4673

74+
//////////////////////////////////////////////////////////////////////////////
75+
// since the joins are considered non-directional edges, if an instance has //
76+
// joins A -> B, and B -> A, only one of them gets built (say, A -> B) //
77+
// But then later, in {@code JoinConnectionList.matchesJoinPath}, a false //
78+
// negative could be returned if the other one (B -> A) was tested for. //
79+
// so - this listing hash keeps track of all joins that are equivalent //
80+
// to one another from this POV, so that any/all can be considered to match //
81+
//////////////////////////////////////////////////////////////////////////////
82+
private ListingHash<NormalizedJoin, String> flippedJoins = new ListingHash<>();
83+
4784
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
4885
// as an instance grows, with the number of joins (say, more than 50?), especially as they may have a lot of connections, //
4986
// it can become very very slow to process a full join graph (e.g., 10 seconds, maybe much worse, per Big-O...) //
@@ -66,81 +103,71 @@ private record Edge(String joinName, String leftTable, String rightTable)
66103

67104

68105

69-
/*******************************************************************************
106+
/***************************************************************************
70107
** In this class, we are treating joins as non-directional graph edges - so -
71108
** use this class to "normalize" what may otherwise be duplicated joins in the
72109
** qInstance (e.g., A -> B and B -> A -- in the instance, those are valid, but
73110
** in our graph here, we want to consider those the same).
74-
*******************************************************************************/
75-
private static class NormalizedJoin
111+
***************************************************************************/
112+
private record NormalizedJoin(String tableA, String tableB, List<String> joinFieldA, List<String> joinFieldB)
76113
{
77-
private String tableA;
78-
private String tableB;
79-
private String joinFieldA;
80-
private String joinFieldB;
81-
82-
83-
84-
/*******************************************************************************
85-
**
86-
*******************************************************************************/
87-
public NormalizedJoin(QJoinMetaData joinMetaData)
114+
/***************************************************************************
115+
*
116+
***************************************************************************/
117+
static NormalizedJoin build(QJoinMetaData joinMetaData)
88118
{
89-
boolean needFlip = false;
119+
List<String> leftFields = joinMetaData.getJoinOns().stream().map(jo -> jo.getLeftField()).toList();
120+
List<String> rightFields = joinMetaData.getJoinOns().stream().map(jo -> jo.getRightField()).toList();
121+
122+
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
123+
// to normalize the join, we'll first compare table names. if they match (a self-join), then we'll compare join fields //
124+
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
125+
Boolean leftFirst = null;
90126
int tableCompare = joinMetaData.getLeftTable().compareTo(joinMetaData.getRightTable());
91127
if(tableCompare < 0)
92128
{
93-
needFlip = true;
129+
leftFirst = true;
130+
}
131+
else if(tableCompare > 0)
132+
{
133+
leftFirst = false;
94134
}
95-
else if(tableCompare == 0)
135+
else
96136
{
97-
int fieldCompare = joinMetaData.getJoinOns().get(0).getLeftField().compareTo(joinMetaData.getJoinOns().get(0).getRightField());
98-
if(fieldCompare < 0)
137+
for(int i = 0; i < Math.min(leftFields.size(), rightFields.size()); i++)
99138
{
100-
needFlip = true;
139+
int fieldCompare = leftFields.get(i).compareTo(rightFields.get(i));
140+
if(fieldCompare < 0)
141+
{
142+
leftFirst = true;
143+
break;
144+
}
145+
else if(fieldCompare > 0)
146+
{
147+
leftFirst = false;
148+
break;
149+
}
101150
}
102151
}
103152

104-
if(needFlip)
153+
if(leftFirst == null)
105154
{
106-
joinMetaData = joinMetaData.flip();
155+
///////////////////////////////////////////////////////////////////////////////////////////////////
156+
// if the sides of the joins were identical (e.g., foo.id -> foo.id), that's probably bad setup. //
157+
// so warn the user about it, and choose something... //
158+
///////////////////////////////////////////////////////////////////////////////////////////////////
159+
LOG.warn("There appears to be a join between a table and itself, with all matching join-fields. This could introduce unexpected behavior.", logPair("joinName", joinMetaData.getName()));
160+
leftFirst = true;
107161
}
108162

109-
tableA = joinMetaData.getLeftTable();
110-
tableB = joinMetaData.getRightTable();
111-
joinFieldA = joinMetaData.getJoinOns().get(0).getLeftField();
112-
joinFieldB = joinMetaData.getJoinOns().get(0).getRightField();
113-
}
114-
115-
116-
117-
/*******************************************************************************
118-
**
119-
*******************************************************************************/
120-
@Override
121-
public boolean equals(Object o)
122-
{
123-
if(this == o)
163+
if(leftFirst)
124164
{
125-
return true;
165+
return (new NormalizedJoin(joinMetaData.getLeftTable(), joinMetaData.getRightTable(), leftFields, rightFields));
126166
}
127-
if(o == null || getClass() != o.getClass())
167+
else
128168
{
129-
return false;
169+
return (new NormalizedJoin(joinMetaData.getRightTable(), joinMetaData.getLeftTable(), rightFields, leftFields));
130170
}
131-
NormalizedJoin that = (NormalizedJoin) o;
132-
return Objects.equals(tableA, that.tableA) && Objects.equals(tableB, that.tableB) && Objects.equals(joinFieldA, that.joinFieldA) && Objects.equals(joinFieldB, that.joinFieldB);
133-
}
134-
135-
136-
137-
/*******************************************************************************
138-
**
139-
*******************************************************************************/
140-
@Override
141-
public int hashCode()
142-
{
143-
return Objects.hash(tableA, tableB, joinFieldA, joinFieldB);
144171
}
145172
}
146173

@@ -155,7 +182,9 @@ public JoinGraph(QInstance qInstance)
155182
Set<NormalizedJoin> usedJoins = new HashSet<>();
156183
for(QJoinMetaData join : CollectionUtils.nonNullMap(qInstance.getJoins()).values())
157184
{
158-
NormalizedJoin normalizedJoin = new NormalizedJoin(join);
185+
NormalizedJoin normalizedJoin = NormalizedJoin.build(join);
186+
flippedJoins.add(normalizedJoin, join.getName());
187+
159188
if(usedJoins.contains(normalizedJoin))
160189
{
161190
continue;
@@ -251,6 +280,58 @@ public boolean matchesJoinPath(List<String> joinPath)
251280

252281

253282

283+
/*******************************************************************************
284+
* version of matchesJoinPath that considers flippedJoins, rather than only
285+
* strictly matching the exact join names in the path (which, given the fact that
286+
* the join graph may contain flipped joins, this allows for more flexible
287+
* (and probably accurate for what you're looking for) matching).
288+
*******************************************************************************/
289+
public boolean matchesJoinPath(List<String> joinPath, JoinGraph joinGraph, QInstance qInstance)
290+
{
291+
if(list.size() != joinPath.size())
292+
{
293+
return (false);
294+
}
295+
296+
OUTER:
297+
for(int i = 0; i < list.size(); i++)
298+
{
299+
JoinConnection joinConnection = list.get(i);
300+
if(joinConnection.viaJoinName().equals(joinPath.get(i)))
301+
{
302+
/////////////////////////////////////////////////////////////////////////
303+
// if the name is an exact match, move on to the next join in the path //
304+
/////////////////////////////////////////////////////////////////////////
305+
continue OUTER;
306+
}
307+
308+
///////////////////////////////////////////////////////////////////////////////
309+
// else consider if any flipped joins match this entry - and if so, continue //
310+
///////////////////////////////////////////////////////////////////////////////
311+
QJoinMetaData join = qInstance.getJoin(joinConnection.viaJoinName);
312+
if(join != null)
313+
{
314+
List<String> joinNames = joinGraph.flippedJoins.get(NormalizedJoin.build(join));
315+
for(String joinName : CollectionUtils.nonNullList(joinNames))
316+
{
317+
if(joinName.equals(joinPath.get(i)))
318+
{
319+
continue OUTER;
320+
}
321+
}
322+
}
323+
324+
/////////////////////////////////////////////////////////////////
325+
// if both checks above fail, then the join path doesn't match //
326+
/////////////////////////////////////////////////////////////////
327+
return (false);
328+
}
329+
330+
return (true);
331+
}
332+
333+
334+
254335
/*******************************************************************************
255336
**
256337
*******************************************************************************/

qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,15 +1122,15 @@ private void validateExposedJoins(QInstance qInstance, JoinGraph joinGraph, QTab
11221122
boolean foundJoinConnection = false;
11231123
for(JoinGraph.JoinConnectionList joinConnectionList : joinConnectionsForTable)
11241124
{
1125-
if(joinConnectionList.matchesJoinPath(exposedJoin.getJoinPath()))
1125+
if(joinConnectionList.matchesJoinPath(exposedJoin.getJoinPath(), joinGraph, qInstance))
11261126
{
11271127
foundJoinConnection = true;
11281128
}
11291129
}
11301130
assertCondition(foundJoinConnection, joinPrefix + "specified a joinPath [" + exposedJoin.getJoinPath() + "] which does not match a valid join connection in the instance.");
11311131
}
11321132

1133-
assertCondition(!usedJoinPaths.contains(exposedJoin.getJoinPath()), tablePrefix + "has more than one join with the joinPath: " + exposedJoin.getJoinPath());
1133+
assertCondition(!usedJoinPaths.contains(exposedJoin.getJoinPath()), tablePrefix + "has more than one exposed join with the joinPath: " + exposedJoin.getJoinPath());
11341134
usedJoinPaths.add(exposedJoin.getJoinPath());
11351135
}
11361136
}

0 commit comments

Comments
 (0)