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}