2626import java .util .Comparator ;
2727import java .util .HashSet ;
2828import java .util .List ;
29- import java .util .Objects ;
3029import java .util .Set ;
3130import java .util .TreeSet ;
3231import com .kingsrook .qqq .backend .core .instances .QMetaDataVariableInterpreter ;
32+ import com .kingsrook .qqq .backend .core .logging .QLogger ;
3333import com .kingsrook .qqq .backend .core .model .metadata .QInstance ;
3434import com .kingsrook .qqq .backend .core .model .metadata .joins .QJoinMetaData ;
3535import com .kingsrook .qqq .backend .core .utils .CollectionUtils ;
36+ import com .kingsrook .qqq .backend .core .utils .ListingHash ;
3637import 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 *******************************************************************************/
4368public 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 *******************************************************************************/
0 commit comments