001package gudusoft.gsqlparser.lineage.v2.merge; 002 003import gudusoft.gsqlparser.lineage.v2.json.CanonicalJson; 004import gudusoft.gsqlparser.lineage.v2.model.Document; 005import gudusoft.gsqlparser.lineage.v2.validation.DocumentIndex; 006 007import java.util.ArrayList; 008import java.util.Collections; 009import java.util.List; 010import java.util.Map; 011import java.util.TreeMap; 012 013import static gudusoft.gsqlparser.lineage.v2.validation.DocumentIndex.items; 014 015/** 016 * The {@code SEMANTIC} comparison of two documents (specification Sections 9.7 to 9.9; contract: section 7 of 017 * {@code docs/designs/data-lineage/05-sqllr-m1-core-library-plan-zh.md}). Objects are matched by identity, never by 018 * document-local reference: analysis contexts by content, endpoints by portable id, Facts by {@code FactIdV1}, 019 * Occurrences by logical key (all records of one key together, so a status change is reported as one change even 020 * when the analysis context changed too), statements by unit id and ordinal, writes by statement, target and 021 * assignment ordinal, ExplainGraph nodes by logical key. Compared values replace each reference by the identity of 022 * what it names, so a change of a referenced object (an evidence item, a candidate's outcomes) shows; objects 023 * without an identity are compared only through what refers to them, and a note counts those left unmatched. 024 * 025 * <p>The diff document of the specification is not defined yet (Section 20, item 6), so the comparison is an 026 * in-memory {@link Comparison}.</p> 027 * 028 * @since 4.2.17 029 */ 030public final class LineageComparator { 031 032 /** The kinds of change, in the order they are reported. */ 033 public enum Kind { 034 /** An analysis context only the second document has (reported first, Section 9.9). */ 035 CONTEXT_ADDED, 036 CONTEXT_REMOVED, 037 ENDPOINT_ADDED, 038 ENDPOINT_REMOVED, 039 /** An endpoint of one portable identity whose other attributes (kind, name, display) changed. */ 040 ENDPOINT_CHANGED, 041 FACT_ADDED, 042 FACT_REMOVED, 043 /** A Fact of one identity whose other attributes changed. */ 044 FACT_CHANGED, 045 OCCURRENCE_ADDED, 046 OCCURRENCE_REMOVED, 047 /** The proof status, a binding or the evidence of one logical Occurrence changed (Section 9.7). */ 048 STATUS_CHANGED, 049 /** The completeness of the document, a unit, a statement, a write, a feature, serving or the ExplainGraph. */ 050 COMPLETENESS_CHANGED, 051 /** A statement or an ExplainGraph node moved and nothing else about it changed. */ 052 LOCATION_CHANGED 053 } 054 055 /** One difference. */ 056 public static final class Change { 057 private final Kind kind; 058 private final String key; 059 private final Object before; 060 private final Object after; 061 062 Change(Kind kind, String key, Object before, Object after) { 063 this.kind = kind; 064 this.key = key; 065 this.before = before == null ? null : CanonicalJson.immutableCopy(before); 066 this.after = after == null ? null : CanonicalJson.immutableCopy(after); 067 } 068 069 public Kind getKind() { 070 return kind; 071 } 072 073 /** The identity of what changed: an id, a logical key, or a composite key. */ 074 public String getKey() { 075 return key; 076 } 077 078 /** The compared value in the first document (frozen JSON, references as identities), or null. */ 079 public Object getBefore() { 080 return before; 081 } 082 083 /** The compared value in the second document, or null. */ 084 public Object getAfter() { 085 return after; 086 } 087 088 @Override 089 public String toString() { 090 return kind + " " + key + (before == null && after == null ? "" 091 : ": " + CanonicalJson.jcsText(before) + " -> " + CanonicalJson.jcsText(after)); 092 } 093 } 094 095 /** The differences of two documents, in the order of {@link Kind}. */ 096 public static final class Comparison { 097 private final List<Change> changes; 098 private final List<String> notes; 099 100 Comparison(List<Change> changes, List<String> notes) { 101 this.changes = Collections.unmodifiableList(changes); 102 this.notes = Collections.unmodifiableList(notes); 103 } 104 105 public List<Change> getChanges() { 106 return changes; 107 } 108 109 public boolean isEmpty() { 110 return changes.isEmpty(); 111 } 112 113 public List<String> getNotes() { 114 return notes; 115 } 116 117 @Override 118 public String toString() { 119 return changes.toString(); 120 } 121 } 122 123 private LineageComparator() { 124 } 125 126 public static Comparison compare(Document before, Document after) { 127 View a = new View(before.toBytes()); 128 View b = new View(after.toBytes()); 129 List<Change> changes = new ArrayList<Change>(); 130 List<String> notes = new ArrayList<String>(); 131 diffSets(changes, Kind.CONTEXT_ADDED, Kind.CONTEXT_REMOVED, null, a.contexts, b.contexts); 132 diffSets(changes, Kind.ENDPOINT_ADDED, Kind.ENDPOINT_REMOVED, Kind.ENDPOINT_CHANGED, a.endpoints, b.endpoints); 133 diffSets(changes, Kind.FACT_ADDED, Kind.FACT_REMOVED, Kind.FACT_CHANGED, a.facts, b.facts); 134 diffSets(changes, Kind.OCCURRENCE_ADDED, Kind.OCCURRENCE_REMOVED, Kind.STATUS_CHANGED, a.occurrences, 135 b.occurrences); 136 diffUnion(changes, Kind.COMPLETENESS_CHANGED, a.completeness, b.completeness); 137 diffValues(changes, Kind.LOCATION_CHANGED, a.locations, b.locations); 138 for (View view : new View[] {a, b}) { 139 if (view.unidentified > 0) { 140 notes.add((view == a ? "first" : "second") + " document: " + view.unidentified 141 + " Occurrences or writes have no identity; they are compared only through what refers to them"); 142 } 143 } 144 return new Comparison(changes, notes); 145 } 146 147 private static void diffSets(List<Change> changes, Kind added, Kind removed, Kind changed, Map<String, Object> a, 148 Map<String, Object> b) { 149 for (Map.Entry<String, Object> entry : b.entrySet()) { 150 if (!a.containsKey(entry.getKey())) { 151 changes.add(new Change(added, entry.getKey(), null, entry.getValue())); 152 } 153 } 154 for (Map.Entry<String, Object> entry : a.entrySet()) { 155 if (!b.containsKey(entry.getKey())) { 156 changes.add(new Change(removed, entry.getKey(), entry.getValue(), null)); 157 } 158 } 159 if (changed != null) { 160 diffValues(changes, changed, a, b); 161 } 162 } 163 164 /** Changed values, and values present on one side only (an ExplainGraph added or removed, for instance). */ 165 private static void diffUnion(List<Change> changes, Kind kind, Map<String, Object> a, Map<String, Object> b) { 166 java.util.TreeSet<String> keys = new java.util.TreeSet<String>(a.keySet()); 167 keys.addAll(b.keySet()); 168 for (String key : keys) { 169 Object x = a.get(key); 170 Object y = b.get(key); 171 if (x == null || y == null || !CanonicalJson.jcsText(x).equals(CanonicalJson.jcsText(y))) { 172 changes.add(new Change(kind, key, x, y)); 173 } 174 } 175 } 176 177 private static void diffValues(List<Change> changes, Kind kind, Map<String, Object> a, Map<String, Object> b) { 178 for (Map.Entry<String, Object> entry : a.entrySet()) { 179 Object other = b.get(entry.getKey()); 180 if (other != null && !CanonicalJson.jcsText(entry.getValue()).equals(CanonicalJson.jcsText(other))) { 181 changes.add(new Change(kind, entry.getKey(), entry.getValue(), other)); 182 } 183 } 184 } 185 186 /** What a document says, keyed by identity. */ 187 private static final class View { 188 final Map<String, Object> contexts = new TreeMap<String, Object>(); 189 final Map<String, Object> endpoints = new TreeMap<String, Object>(); 190 final Map<String, Object> facts = new TreeMap<String, Object>(); 191 final Map<String, Object> occurrences = new TreeMap<String, Object>(); 192 final Map<String, Object> completeness = new TreeMap<String, Object>(); 193 final Map<String, Object> locations = new TreeMap<String, Object>(); 194 int unidentified; 195 private final Semantics semantics; 196 197 View(byte[] document) { 198 semantics = new Semantics(document); 199 DocumentIndex index = semantics.index(); 200 Map<?, ?> doc = index.document(); 201 for (Map<?, ?> context : items(doc.get("analysisContexts"))) { 202 contexts.put(semantics.keyedIdentity(context.get("ref")), semantics.formOf(context.get("ref"))); 203 } 204 for (Map<?, ?> endpoint : index.all("endpoint")) { 205 String identity = semantics.keyedIdentity(endpoint.get("ref")); 206 if (identity != null) { 207 add(endpoints, identity, semantics.formOf(endpoint.get("ref"))); 208 } 209 } 210 for (Map<?, ?> fact : index.all("fact")) { 211 String identity = semantics.keyedIdentity(fact.get("ref")); 212 if (identity != null) { 213 add(facts, identity, semantics.formOf(fact.get("ref"))); 214 } 215 } 216 for (Map<?, ?> occurrence : index.all("occurrence")) { 217 String identity = semantics.keyedIdentity(occurrence.get("ref")); 218 if (identity == null) { 219 unidentified++; 220 continue; 221 } 222 Map<String, Object> record = new TreeMap<String, Object>(); 223 for (Map.Entry<?, ?> member : ((Map<?, ?>) semantics.formOf(occurrence.get("ref"))).entrySet()) { 224 String name = String.valueOf(member.getKey()); 225 // the analysis context of the record is compared with the contexts, not with each record 226 if (!"recordId".equals(name) && !"statementRef".equals(name)) { 227 record.put(name, member.getValue()); 228 } 229 } 230 add(occurrences, identity, record); 231 } 232 completeness.put("document", Collections.singletonList(doc.get("analysisCompleteness"))); 233 completeness.put("serving", semantics.form(doc.get("servingCompleteness"), "servingCompleteness")); 234 completeness.put("features", doc.get("featureCompleteness") == null ? Collections.emptyList() 235 : semantics.form(doc.get("featureCompleteness"), "featureCompleteness")); 236 Map<?, ?> graph = index.graph(); 237 if (index.hasGraph()) { 238 List<Object> graphCompleteness = new ArrayList<Object>(); 239 graphCompleteness.add(graph.get("completeness")); 240 graphCompleteness.add(graph.get("statementCompleteness") == null ? Collections.emptyList() 241 : semantics.form(graph.get("statementCompleteness"), "explainGraph.statementCompleteness")); 242 completeness.put("explainGraph", graphCompleteness); 243 } 244 for (Map<?, ?> unit : index.all("sourceUnit")) { 245 String identity = semantics.keyedIdentity(unit.get("ref")); 246 if (identity != null) { 247 add(completeness, identity, scopeCompleteness(unit, "sourceUnits[].featureCompleteness")); 248 } 249 } 250 for (Map<?, ?> statement : index.all("statement")) { 251 String identity = semantics.keyedIdentity(statement.get("ref")); 252 if (identity != null) { 253 add(completeness, identity, scopeCompleteness(statement, "statements[].featureCompleteness")); 254 Map<String, Object> where = new TreeMap<String, Object>(); 255 where.put("start", statement.get("start")); 256 where.put("end", statement.get("end")); 257 add(locations, identity, where); 258 } 259 } 260 for (Map<?, ?> write : index.all("tfo")) { 261 String statement = semantics.keyedIdentity(write.get("statementRef")); 262 String target = semantics.keyedIdentity(write.get("targetRef")); 263 if (statement == null || target == null) { 264 unidentified++; 265 continue; 266 } 267 Map<String, Object> value = new TreeMap<String, Object>(); 268 for (String member : new String[] {"upstreamCompleteness", "upstreamDisposition", 269 "upstreamDispositionReason"}) { 270 if (write.containsKey(member)) { 271 value.put(member, write.get(member)); 272 } 273 } 274 add(completeness, "write " + statement + " " + target + " " + write.get("assignmentOrdinal"), value); 275 } 276 for (Map<?, ?> node : index.all("node")) { 277 String identity = semantics.keyedIdentity(node.get("ref")); 278 if (identity != null && node.get("span") != null) { 279 add(locations, identity, semantics.form(node.get("span"), "explainGraph.nodes[].span")); 280 } 281 } 282 } 283 284 private Object scopeCompleteness(Map<?, ?> scope, String path) { 285 Map<String, Object> value = new TreeMap<String, Object>(); 286 value.put("analysisCompleteness", scope.get("analysisCompleteness")); 287 if (scope.get("featureCompleteness") != null) { 288 value.put("featureCompleteness", semantics.form(scope.get("featureCompleteness"), path)); 289 } 290 return value; 291 } 292 293 /** Several objects of one identity (one per analysis context, for instance) are compared as a multiset. */ 294 @SuppressWarnings("unchecked") 295 private static void add(Map<String, Object> into, String identity, Object value) { 296 List<Object> values = (List<Object>) into.get(identity); 297 if (values == null) { 298 values = new ArrayList<Object>(); 299 into.put(identity, values); 300 } 301 values.add(value); 302 List<String> texts = new ArrayList<String>(); 303 for (Object item : values) { 304 texts.add(CanonicalJson.jcsText(item)); 305 } 306 Collections.sort(texts); 307 values.clear(); 308 for (String text : texts) { 309 values.add(CanonicalJson.parse(text.getBytes(java.nio.charset.StandardCharsets.UTF_8))); 310 } 311 } 312 } 313}