Semantic Diff for SQL
Motivation
Software is constantly changing and evolving, and identifying what has changed and reviewing those changes is an integral part of the development process. SQL code is no exception to this.
Text-based diff tools such as git diff, when applied to a code base, have certain limitations. First, they can only detect insertions and deletions, not movements or updates of individual pieces of code. Second, such tools can only detect changes between lines of text, which is too coarse for something as granular and detailed as source code. Additionally, the outcome of such a diff is dependent on the underlying code formatting, and yields different results if the formatting should change.
Consider the following diff generated by Git:
Semantically the query hasn’t changed. The two arguments b and c have been swapped (moved), posing no impact on the output of the query. Yet Git replaced the whole affected expression alongside a bulk of unrelated elements.
The alternative to text-based diffing is to compare Abstract Syntax Trees (AST) instead. The main advantage of ASTs are that they are a direct product of code parsing, which represents the underlying code structure at any desired level of granularity. Comparing ASTs may yield extremely precise diffs; changes such as code movements and updates can also be detected. Even more importantly, this approach facilitates additional use cases beyond eyeballing two versions of source code side by side.
The use cases I had in mind for SQL when I decided to embark on this journey of semantic diffing were the following:
- Query similarity score. Identifying which parts the two queries have in common to automatically suggest opportunities for consolidation, creation of intermediate/staging tables, and so on.
- Differentiating between cosmetic / structural changes and functional ones. For example when a nested query is refactored into a common table expression (CTE), this kind of change doesn’t have any functional impact on either a query or its outcome.
- Automatic suggestions about the need to retroactively backfill data. This is especially important for pipelines that populate very large tables for which restatement is a runtime-intensive procedure. The ability to discern between simple code movements and actual modifications can help assess the impact of a change and make suggestions accordingly.
The implementation discussed in this post is now a part of the SQLGlot library. You can find a complete source code in the diff.py module. The choice of SQLglot was an obvious one due to its simple but powerful API, lack of external dependencies and, more importantly, extensive list of supported SQL dialects.
The Search for a Solution
When it comes to any diffing tool (not just a semantic one), the primary challenge is to match as many elements of compared entities as possible. Once such a set of matching elements is available, deriving a sequence of changes becomes an easy task.
If our elements have unique identifiers associated with them (for example, an element’s ID in DOM), the matching problem is trivial. However, the SQL syntax trees that we are comparing have neither unique keys nor object identifiers that can be used for the purposes of matching. So, how do we suppose to find pairs of nodes that are related?
To better illustrate the problem, consider comparing the following SQL expressions: SELECT a + b + c, d, e and SELECT a - b + c, e, f. Matching individual nodes from respective syntax trees can be visualized as follows:
Figure 1: Example of node matching for two SQL expression trees.
By looking at the figure of node matching for two SQL expression trees above, we conclude that the following changes should be captured by our solution:
- Inserted nodes:
Subandf. These are the nodes from the target AST which do not have a matching node in the source AST. - Removed nodes:
Addandd. These are the nodes from the source AST which do not have a counterpart in the target AST. - Remaining nodes must be identified as unchanged.
It should be clear at this point that if we manage to match nodes in the source tree with their counterparts in the target tree, then computing the diff becomes a trivial matter.
Naïve Brute-Force
The naïve solution would be to try all different permutations of node pair combinations, and see which set of pairs performs the best based on some type of heuristics. The runtime cost of such a solution quickly reaches the escape velocity; if both trees had only 10 nodes each, the number of such sets would approximately be 10! ^ 2 = 3.6M ^ 2 ~= 13 * 10^12. This is a very bad case of factorial complexity (to be precise, it’s actually much worse - O(n! ^ 2) - but I couldn’t come up with a name for it), so there is little need to explore this approach any further.
Myers Algorithm
After the naïve approach was proven to be infeasible, the next question I asked myself was “how does git diff work?”. This question led me to discover the Myers diff algorithm [1]. This algorithm has been designed to compare sequences of strings. At its core, it’s looking for the shortest path on a graph of possible edits that transform the first sequence into the second one, while heavily rewarding those paths that lead to longest subsequences of unchanged elements. There’s a lot of material out there describing this algorithm in greater detail. I found James Coglan’s series of blog posts to be the most comprehensive.
Therefore, I had this “brilliant” (actually not) idea to transform trees into sequences by traversing them in topological order, and then applying the Myers algorithm on resulting sequences while using a custom heuristics when checking the equality of two nodes. Unsurprisingly, comparing sequences of strings is quite different from comparing hierarchical tree structures, and by flattening trees into sequences, we lose a lot of relevant context. This resulted in a terrible performance of this algorithm on ASTs. It often matched completely unrelated nodes, even when the two trees were mostly the same, and produced extremely inaccurate lists of changes overall. After playing around with it a little and tweaking my equality heuristics to improve accuracy, I ultimately scrapped the whole implementation and went back to the drawing board.
Change Distiller
The algorithm I settled on at the end was Change Distiller, created by Fluri et al. [2], which in turn is an improvement over the core idea described by Chawathe et al. [3].
The algorithm consists of two high-level steps:
- Finding appropriate matchings between pairs of nodes that are part of compared ASTs. Identifying what is meant by “appropriate” matching is also a part of this step.
- Generating the so-called “edit script” from the matching set built in the 1st step. The edit script is a sequence of edit operations (for example, insert, remove, update, etc.) on individual tree nodes, such that when applied as transformations on the source AST, it eventually becomes the target AST. In general, the shorter the sequence, the better. The length of the edit script can be used to compare the performance of different algorithms, though this is not the only metric that matters.
The rest of this section is dedicated to the Python implementation of the steps above using the AST implementation provided by the SQLGlot library.
Building the Matching Set
Matching Leaves
We begin composing the matching set by matching the leaf nodes. Leaf nodes are the nodes that do not have any children nodes (such as literals, identifiers, etc.). In order to match them, we gather all the leaf nodes from the source tree and generate a cartesian product with all the leaves from the target tree, while comparing pairs created this way and assigning them a similarity score. During this stage, we also exclude pairs that don’t pass basic matching criteria. Then, we pick pairs that scored the highest while making sure that each node is matched no more than once.
Using the example provided at the beginning of the post, the process of building an initial set of candidate matchings can be seen on Figure 2.
Figure 2: Building a set of candidate matchings between leaf nodes. The third item in each triplet represents a similarity score between two nodes.
First, let’s analyze the similarity score. Then, we’ll discuss matching criteria.
The similarity score proposed by Fluri et al. [2] is a dice coefficient applied to bigrams of respective node values. A bigram is a sequence of two adjacent elements from a string computed in a sliding window fashion:
def bigram(string):
count = max(0, len(string) - 1)
return [string[i : i + 2] for i in range(count)]
For reasons that will become clear shortly, we actually need to compute bigram histograms rather than just sequences:
from collections import defaultdict
def bigram_histo(string):
count = max(0, len(string) - 1)
bigram_histo = defaultdict(int)
for i in range(count):
bigram_histo[string[i : i + 2]] += 1
return bigram_histo
The dice coefficient formula looks like following:
Where X is a bigram of the source node and Y is a bigram of the second one. What this essentially does is count the number of bigram elements the two nodes have in common, multiply it by 2, and then divide by the total number of elements in both bigrams. This is where bigram histograms come in handy:
def dice_coefficient(source, target):
source_histo = bigram_histo(source.sql())
target_histo = bigram_histo(target.sql())
total_grams = (
sum(source_histo.values()) + sum(target_histo.values())
)
if not total_grams:
return 1.0 if source == target else 0.0
overlap_len = 0
overlapping_grams = set(source_histo) & set(target_histo)
for g in overlapping_grams:
overlap_len += min(source_histo[g], target_histo[g])
return 2 * overlap_len / total_grams
To compute a bigram given a tree node, we first transform the node into its canonical SQL representation,so that the Literal(123) node becomes just “123” and the Identifier(“a”) node becomes just “a”. We also handle a scenario when strings are too short to derive bigrams. In this case, we fallback to checking the two nodes for equality.
Now when we know how to compute the similarity score, we can take care of the matching criteria for leaf nodes. In the original paper [2], the matching criteria is formalized as follows:
The two nodes are matched if two conditions are met:
- The node labels match (in our case labels are just node types).
- The similarity score for node values is greater than or equal to some threshold “f”. The authors of the paper recommend setting the value of “f” to 0.6.
With building blocks in place, we can now build a matching set for leaf nodes. First, we generate a list of candidates for matching:
from heapq import heappush, heappop
candidate_matchings = []
source_leaves = _get_leaves(self._source)
target_leaves = _get_leaves(self._target)
for source_leaf in source_leaves:
for target_leaf in target_leaves:
if _is_same_type(source_leaf, target_leaf):
similarity_score = dice_coefficient(
source_leaf, target_leaf
)
if similarity_score >= 0.6:
heappush(
candidate_matchings,
(
-similarity_score,
len(candidate_matchings),
source_leaf,
target_leaf,
),
)
In the implementation above, we push each matching pair onto the heap to automatically maintain the correct order based on the assigned similarity score.
Finally, we build the initial matching set by picking leaf pairs with the highest score:
matching_set = set()
while candidate_matchings:
_, _, source_leaf, target_leaf = heappop(candidate_matchings)
if (
source_leaf in unmatched_source_nodes
and target_leaf in unmatched_target_nodes
):
matching_set.add((source_leaf, target_leaf))
unmatched_source_nodes.remove(source_leaf)
unmatched_target_nodes.remove(target_leaf)
To finalize the matching set, we should now proceed with matching inner nodes.
Matching Inner Nodes
Matching inner nodes is quite similar to matching leaf nodes, with the following two distinctions:
- Rather than ranking a set of possible candidates, we pick the first node pair that passes the matching criteria.
- The matching criteria itself has been extended to account for the number of leaf nodes the pair of inner nodes have in common.
Figure 3: Matching inner nodes based on their type as well as how many of their leaf nodes have been previously matched.
Let’s start with the matching criteria. The criteria is formalized as follows:
Alongside already familiar similarity score and node type criteria, there is a new one in the middle: the ratio of leaf nodes that the two nodes have in common must exceed some threshold “t”. The recommended value for “t” is also 0.6. Counting the number of common leaf nodes is pretty straightforward, since we already have the complete matching set for leaves. All we need to do is count how many matching pairs do leaf nodes from the two compared inner nodes form.
There are two additional heuristics associated with this matching criteria:
- Inner node similarity weighting: if the similarity score between the node values doesn’t pass the threshold “f” but the ratio of common leaf nodes (“t”) is greater than or equal to 0.8, then the matching is considered successful.
- The threshold “t” is reduced to 0.4 for inner nodes with the number of leaf nodes equal to 4 or less, in order to decrease the false negative rate for small subtrees.
We now only have to iterate through the remaining unmatched nodes and form matching pairs based on the outlined criteria:
leaves_matching_set = matching_set.copy()
for source_node in unmatched_source_nodes.copy():
for target_node in unmatched_target_nodes:
if _is_same_type(source_node, target_node):
source_leaves = set(_get_leaves(source_node))
target_leaves = set(_get_leaves(target_node))
max_leaves_num = max(len(source_leaves), len(target_leaves))
if max_leaves_num:
common_leaves_num = sum(
1 if s in source_leaves and t in target_leaves else 0
for s, t in leaves_matching_set
)
leaf_similarity_score = common_leaves_num / max_leaves_num
else:
leaf_similarity_score = 0.0
adjusted_t = (
0.6
if min(len(source_leaves), len(target_leaves)) > 4
else 0.4
)
if leaf_similarity_score >= 0.8 or (
leaf_similarity_score >= adjusted_t
and dice_coefficient(source_node, target_node) >= 0.6
):
matching_set.add((source_node, target_node))
unmatched_source_nodes.remove(source_node)
unmatched_target_nodes.remove(target_node)
break
After the matching set is formed, we can proceed with generation of the edit script, which will be the algorithm’s output.
Generating the Edit Script
At this point, we should have the following 3 sets at our disposal:
- The set of matched node pairs.
- The set of remaining unmatched nodes from the source tree.
- The set of remaining unmatched nodes from the target tree.
We can derive 3 kinds of edits from the matching set: either the node’s value was updated (Update), the node was moved to a different position within the tree (Move), or the node remained unchanged (Keep). Note that the Move case is not mutually exclusive with the other two. The node could have been updated or could have remained the same while at the same time its position within its parent node or the parent node itself could have changed. All unmatched nodes from the source tree are the ones that were removed (Remove), while unmatched nodes from the target tree are the ones that were inserted (Insert).
The latter two cases are pretty straightforward to implement:
edit_script = []
for removed_node in unmatched_source_nodes:
edit_script.append(Remove(removed_node))
for inserted_node in unmatched_target_nodes:
edit_script.append(Insert(inserted_node))
Traversing the matching set requires a little more thought:
for source_node, target_node in matching_set:
if (
not isinstance(source_node, LEAF_EXPRESSION_TYPES)
or source_node == target_node
):
move_edits = generate_move_edits(
source_node, target_node, matching_set
)
edit_script.extend(move_edits)
edit_script.append(Keep(source_node, target_node))
else:
edit_script.append(Update(source_node, target_node))
If a matching pair represents a pair of leaf nodes, we check if they are the same to decide whether an update took place. For inner node pairs, we also need to compare the positions of their respective children to detect node movements. Chawathe et al. [3] suggest applying the longest common subsequence (LCS) algorithm which, no surprise here, was described by Myers himself [1]. There is a small catch, however: instead of checking the equality of two children nodes, we need to check whether the two nodes form a pair that is a part of our matching set.
Now with this knowledge, the implementation becomes straightforward:
def generate_move_edits(source, target, matching_set):
source_children = _get_child_nodes(source)
target_children = _get_child_nodes(target)
lcs = set(
_longest_common_subsequence(
source_children,
target_children,
lambda l, r: (l, r) in matching_set
)
)
move_edits = []
for node in source_children:
if node not in lcs and node not in unmatched_source_nodes:
move_edits.append(Move(node))
return move_edits
I left out the implementation of the LCS algorithm itself here, but there are plenty of implementation choices out there that can be easily looked up.
Output
The implemented algorithm produces the output that resembles the following:
>>> from sqlglot import parse_one, diff
>>> diff(parse_one("SELECT a + b + c, d, e"), parse_one("SELECT a - b + c, e, f"))
Remove(Add)
Remove(Column(d))
Remove(Identifier(d))
Insert(Sub)
Insert(Column(f))
Insert(Identifier(f))
Keep(Select, Select)
Keep(Add, Add)
Keep(Column(a), Column(a))
Keep(Identifier(a), Identifier(a))
Keep(Column(b), Column(b))
Keep(Identifier(b), Identifier(b))
Keep(Column(c), Column(c))
Keep(Identifier(c), Identifier(c))
Keep(Column(e), Column(e))
Keep(Identifier(e), Identifier(e))
Note that the output above is abbreviated. The string representation of actual AST nodes is significantly more verbose.
The implementation works especially well when coupled with the SQLGlot’s query optimizer which can be used to produce canonical representations of compared queries:
>>> schema={"t": {"a": "INT", "b": "INT", "c": "INT", "d": "INT"}}
>>> source = """
... SELECT 1 + 1 + a
... FROM t
... WHERE b = 1 OR (c = 2 AND d = 3)
... """
>>> target = """
... SELECT 2 + a
... FROM t
... WHERE (b = 1 OR c = 2) AND (b = 1 OR d = 3)
... """
>>> optimized_source = optimize(parse_one(source), schema=schema)
>>> optimized_target = optimize(parse_one(target), schema=schema)
>>> edit_script = diff(optimized_source, optimized_target)
>>> sum(0 if isinstance(e, Keep) else 1 for e in edit_script)
0
Optimizations
The worst case runtime complexity of this algorithm is not exactly stellar: O(n^2 * log n^2). This is because of the leaf matching process, which involves ranking a cartesian product between all leaf nodes of compared trees. Unsurprisingly, the algorithm takes a considerable time to finish for bigger queries.
There are still a few basic things we can do in our implementation to help improve performance:
- Refer to individual node objects using their identifiers (Python’s id()) instead of direct references in sets. This helps avoid costly recursive hash calculations and equality checks.
- Cache bigram histograms to avoid computing them more than once for the same node.
- Compute the canonical SQL string representation for each tree once while caching string representations of all inner nodes. This prevents redundant tree traversals when bigrams are computed.
At the time of writing only the first two optimizations have been implemented, so there is an opportunity to contribute for anyone who’s interested.
Alternative Solutions
This section is dedicated to solutions that I’ve investigated, but haven’t tried.
First, this section wouldn’t be complete without Tristan Hume’s blog post. Tristan’s solution has a lot in common with the Myers algorithm plus heuristics that is much more clever than what I came up with. The implementation relies on a combination of dynamic programming and A* search algorithm to explore the space of possible matchings and pick the best ones. It seemed to have worked well for Tistan’s specific use case, but after my negative experience with the Myers algorithm, I decided to try something different.
Another notable approach is the Gumtree algorithm by Falleri et al. [4]. I discovered this paper after I’d already implemented the algorithm that is the main focus of this post. In sections 5.2 and 5.3 of their paper, the authors compare the two algorithms side by side and claim that Gumtree is significantly better in terms of both runtime performance and accuracy when evaluated on 12 792 pairs of Java source files. This doesn’t surprise me, as the algorithm takes the height of subtrees into account. In my tests, I definitely saw scenarios in which this context would have helped. On top of that, the authors promise O(n^2) runtime complexity in the worst case which, given the Change Distiller's O(n^2 * log n^2), looks particularly tempting. I hope to try this algorithm out at some point, and there is a good chance you see me writing about it in my future posts.
Conclusion
The Change Distiller algorithm yielded quite satisfactory results in most of my tests. The scenarios in which it fell short mostly concerned identical (or very similar) subtrees located in different parts of the AST. In those cases, node mismatches were frequent and, as a result, edit scripts were somewhat suboptimal.
Additionally, the runtime performance of the algorithm leaves a lot to be desired. On trees with 1000 leaf nodes each, the algorithm takes a little under 2 seconds to complete. My implementation still has room for improvement, but this should give you a rough idea of what to expect. It appears that the Gumtree algorithm [4] can help address both of these points. I hope to find bandwidth to work on it soon and then compare the two algorithms side-by-side to find out which one performs better on SQL specifically. In the meantime, Change Distiller definitely gets the job done, and I can now proceed with applying it to some of the use cases I mentioned at the beginning of this post.
I’m also curious to learn whether other folks in the industry faced a similar problem, and how they approached it. If you did something similar, I’m interested to hear about your experience.
References
[1] Eugene W. Myers. An O(ND) Difference Algorithm and Its Variations. Algorithmica 1(2): 251-266 (1986)
[2] B. Fluri, M. Wursch, M. Pinzger, and H. Gall. Change Distilling: Tree differencing for fine-grained source code change extraction. IEEE Trans. Software Eng., 33(11):725–743, 2007.
[3] S.S. Chawathe, A. Rajaraman, H. Garcia-Molina, and J. Widom. Change Detection in Hierarchically Structured Information. Proc. ACM Sigmod Int’l Conf. Management of Data, pp. 493-504, June 1996
[4] Jean-Rémy Falleri, Floréal Morandat, Xavier Blanc, Matias Martinez, Martin Monperrus. Fine-grained and Accurate Source Code Differencing. Proceedings of the International Conference on Automated Software Engineering, 2014, Västeras, Sweden. pp.313-324, 10.1145/2642937.2642982. hal-01054552
1""" 2.. include:: ../posts/sql_diff.md 3 4---- 5""" 6 7from __future__ import annotations 8 9import typing as t 10from collections import defaultdict 11from dataclasses import dataclass 12from heapq import heappop, heappush 13from itertools import chain 14 15from sqlglot import Dialect, expressions as exp 16from sqlglot.helper import seq_get 17 18if t.TYPE_CHECKING: 19 from collections.abc import Iterator, Sequence 20 from sqlglot.dialects.dialect import DialectType 21 22 23@dataclass(frozen=True) 24class Insert: 25 """Indicates that a new node has been inserted""" 26 27 expression: exp.Expr 28 29 30@dataclass(frozen=True) 31class Remove: 32 """Indicates that an existing node has been removed""" 33 34 expression: exp.Expr 35 36 37@dataclass(frozen=True) 38class Move: 39 """Indicates that an existing node's position within the tree has changed""" 40 41 source: exp.Expr 42 target: exp.Expr 43 44 45@dataclass(frozen=True) 46class Update: 47 """Indicates that an existing node has been updated""" 48 49 source: exp.Expr 50 target: exp.Expr 51 52 53@dataclass(frozen=True) 54class Keep: 55 """Indicates that an existing node hasn't been changed""" 56 57 source: exp.Expr 58 target: exp.Expr 59 60 61if t.TYPE_CHECKING: 62 from sqlglot._typing import T 63 64 Edit = t.Union[Insert, Remove, Move, Update, Keep] 65 66 67def diff( 68 source: exp.Expr, 69 target: exp.Expr, 70 matchings: list[tuple[exp.Expr, exp.Expr]] | None = None, 71 delta_only: bool = False, 72 **kwargs: t.Any, 73) -> list[Edit]: 74 """ 75 Returns the list of changes between the source and the target expressions. 76 77 Examples: 78 >>> from sqlglot import parse_one 79 >>> diff(parse_one("a + b"), parse_one("a + c")) # doctest: +SKIP 80 [...] 81 82 Args: 83 source: the source expression. 84 target: the target expression against which the diff should be calculated. 85 matchings: the list of pre-matched node pairs which is used to help the algorithm's 86 heuristics produce better results for subtrees that are known by a caller to be matching. 87 Note: expression references in this list must refer to the same node objects that are 88 referenced in the source / target trees. 89 delta_only: excludes all `Keep` nodes from the diff. 90 kwargs: additional arguments to pass to the ChangeDistiller instance. 91 92 Returns: 93 the list of Insert, Remove, Move, Update and Keep objects for each node in the source and the 94 target expression trees. This list represents a sequence of steps needed to transform the source 95 expression tree into the target one. 96 """ 97 matchings = matchings or [] 98 99 def compute_node_mappings( 100 old_nodes: tuple[exp.Expr, ...], new_nodes: tuple[exp.Expr, ...] 101 ) -> dict[int, exp.Expr]: 102 node_mapping = {} 103 for old_node, new_node in zip(reversed(old_nodes), reversed(new_nodes)): 104 new_node._hash = hash(new_node) 105 node_mapping[id(old_node)] = new_node 106 107 return node_mapping 108 109 # if the source and target have any shared objects, that means there's an issue with the ast 110 # the algorithm won't work because the parent / hierarchies will be inaccurate 111 source_nodes = tuple(source.walk()) 112 target_nodes = tuple(target.walk()) 113 source_ids = {id(n) for n in source_nodes} 114 target_ids = {id(n) for n in target_nodes} 115 116 copy = ( 117 len(source_nodes) != len(source_ids) 118 or len(target_nodes) != len(target_ids) 119 or source_ids & target_ids 120 ) 121 122 source_copy = source.copy() if copy else source 123 target_copy = target.copy() if copy else target 124 125 try: 126 # We cache the hash of each new node here to speed up equality comparisons. If the input 127 # trees aren't copied, these hashes will be evicted before returning the edit script. 128 if copy and matchings: 129 source_mapping = compute_node_mappings(source_nodes, tuple(source_copy.walk())) 130 target_mapping = compute_node_mappings(target_nodes, tuple(target_copy.walk())) 131 matchings = [(source_mapping[id(s)], target_mapping[id(t)]) for s, t in matchings] 132 else: 133 for node in chain(reversed(source_nodes), reversed(target_nodes)): 134 node._hash = hash(node) 135 136 edit_script = ChangeDistiller(**kwargs).diff( 137 source_copy, 138 target_copy, 139 matchings=matchings, 140 delta_only=delta_only, 141 ) 142 finally: 143 if not copy: 144 for node in chain(source_nodes, target_nodes): 145 node._hash = None 146 147 return edit_script 148 149 150# The expression types for which Update edits are allowed. 151UPDATABLE_EXPRESSION_TYPES = ( 152 exp.Alias, 153 exp.Boolean, 154 exp.Column, 155 exp.DataType, 156 exp.Lambda, 157 exp.Literal, 158 exp.Table, 159 exp.Window, 160) 161 162IGNORED_LEAF_EXPRESSION_TYPES = (exp.Identifier,) 163 164 165class ChangeDistiller: 166 """ 167 The implementation of the Change Distiller algorithm described by Beat Fluri and Martin Pinzger in 168 their paper https://ieeexplore.ieee.org/document/4339230, which in turn is based on the algorithm by 169 Chawathe et al. described in http://ilpubs.stanford.edu:8090/115/1/1995-46.pdf. 170 """ 171 172 def __init__(self, f: float = 0.6, t: float = 0.6, dialect: DialectType = None) -> None: 173 self.f = f 174 self.t = t 175 self._sql_generator = Dialect.get_or_raise(dialect).generator(comments=False) 176 177 def diff( 178 self, 179 source: exp.Expr, 180 target: exp.Expr, 181 matchings: list[tuple[exp.Expr, exp.Expr]] | None = None, 182 delta_only: bool = False, 183 ) -> list[Edit]: 184 matchings = matchings or [] 185 pre_matched_nodes = {id(s): id(t) for s, t in matchings} 186 187 self._source = source 188 self._target = target 189 self._source_index = { 190 id(n): n for n in self._source.bfs() if not isinstance(n, IGNORED_LEAF_EXPRESSION_TYPES) 191 } 192 self._target_index = { 193 id(n): n for n in self._target.bfs() if not isinstance(n, IGNORED_LEAF_EXPRESSION_TYPES) 194 } 195 self._unmatched_source_nodes = set(self._source_index) - set(pre_matched_nodes) 196 self._unmatched_target_nodes = set(self._target_index) - set(pre_matched_nodes.values()) 197 self._bigram_histo_cache: dict[int, defaultdict[str, int]] = {} 198 199 matching_set = self._compute_matching_set() | set(pre_matched_nodes.items()) 200 return self._generate_edit_script(dict(matching_set), delta_only) 201 202 def _generate_edit_script(self, matchings: dict[int, int], delta_only: bool) -> list[Edit]: 203 edit_script: list[Edit] = [] 204 205 # The unmatched node sets and the matching set are keyed by id(), so iterating them 206 # directly orders the edits by object address, which varies between processes. The 207 # node indexes are built from bfs(), so iterating those instead keeps the edit script 208 # in a stable, tree-order sequence. 209 for removed_node_id in self._source_index: 210 if removed_node_id in self._unmatched_source_nodes: 211 edit_script.append(Remove(self._source_index[removed_node_id])) 212 for inserted_node_id in self._target_index: 213 if inserted_node_id in self._unmatched_target_nodes: 214 edit_script.append(Insert(self._target_index[inserted_node_id])) 215 for kept_source_node_id in self._source_index: 216 kept_target_node_id = matchings.get(kept_source_node_id) 217 if kept_target_node_id is None: 218 continue 219 220 source_node = self._source_index[kept_source_node_id] 221 target_node = self._target_index[kept_target_node_id] 222 223 identical_nodes = source_node == target_node 224 225 if not isinstance(source_node, UPDATABLE_EXPRESSION_TYPES) or identical_nodes: 226 if identical_nodes: 227 source_parent = source_node.parent 228 target_parent = target_node.parent 229 230 if ( 231 (source_parent and not target_parent) 232 or (not source_parent and target_parent) 233 or ( 234 source_parent 235 and target_parent 236 and matchings.get(id(source_parent)) != id(target_parent) 237 ) 238 ): 239 edit_script.append(Move(source=source_node, target=target_node)) 240 else: 241 edit_script.extend( 242 self._generate_move_edits(source_node, target_node, matchings) 243 ) 244 245 source_non_expression_leaves = dict(_get_non_expression_leaves(source_node)) 246 target_non_expression_leaves = dict(_get_non_expression_leaves(target_node)) 247 248 if source_non_expression_leaves != target_non_expression_leaves: 249 edit_script.append(Update(source_node, target_node)) 250 elif not delta_only: 251 edit_script.append(Keep(source_node, target_node)) 252 else: 253 edit_script.append(Update(source_node, target_node)) 254 255 return edit_script 256 257 def _generate_move_edits( 258 self, source: exp.Expr, target: exp.Expr, matchings: dict[int, int] 259 ) -> list[Move]: 260 source_args = [id(e) for e in _expression_only_args(source)] 261 target_args = [id(e) for e in _expression_only_args(target)] 262 263 args_lcs = set( 264 _lcs(source_args, target_args, lambda l, r: matchings.get(t.cast(int, l)) == r) 265 ) 266 267 move_edits = [] 268 for a in source_args: 269 if a not in args_lcs and a not in self._unmatched_source_nodes: 270 move_edits.append( 271 Move(source=self._source_index[a], target=self._target_index[matchings[a]]) 272 ) 273 274 return move_edits 275 276 def _compute_matching_set(self) -> set[tuple[int, int]]: 277 leaves_matching_set = self._compute_leaf_matching_set() 278 matching_set = leaves_matching_set.copy() 279 280 matched_target_leaves: dict[int, list[int]] = defaultdict(list) 281 for source_leaf_id, target_leaf_id in leaves_matching_set: 282 matched_target_leaves[source_leaf_id].append(target_leaf_id) 283 284 ordered_unmatched_source_nodes = { 285 id(n): None for n in self._source.bfs() if id(n) in self._unmatched_source_nodes 286 } 287 ordered_unmatched_target_nodes = { 288 id(n): None for n in self._target.bfs() if id(n) in self._unmatched_target_nodes 289 } 290 291 target_leaf_ids_cache: dict[int, set[int]] = {} 292 293 for source_node_id in ordered_unmatched_source_nodes: 294 source_node = self._source_index[source_node_id] 295 source_leaf_ids = None 296 297 for target_node_id in ordered_unmatched_target_nodes: 298 target_node = self._target_index[target_node_id] 299 if _is_same_type(source_node, target_node): 300 if source_leaf_ids is None: 301 source_leaf_ids = {id(l) for l in _get_expression_leaves(source_node)} 302 source_leaves_num = len(source_leaf_ids) 303 candidate_target_leaf_ids = [ 304 target_leaf_id 305 for source_leaf_id in source_leaf_ids 306 for target_leaf_id in matched_target_leaves.get(source_leaf_id, ()) 307 ] 308 309 target_leaf_ids = target_leaf_ids_cache.get(target_node_id) 310 if target_leaf_ids is None: 311 target_leaf_ids = {id(l) for l in _get_expression_leaves(target_node)} 312 target_leaf_ids_cache[target_node_id] = target_leaf_ids 313 314 target_leaves_num = len(target_leaf_ids) 315 max_leaves_num = max(source_leaves_num, target_leaves_num) 316 if max_leaves_num: 317 common_leaves_num = sum( 318 1 for t in candidate_target_leaf_ids if t in target_leaf_ids 319 ) 320 leaf_similarity_score = common_leaves_num / max_leaves_num 321 else: 322 leaf_similarity_score = 0.0 323 324 adjusted_t = self.t if min(source_leaves_num, target_leaves_num) > 4 else 0.4 325 326 if leaf_similarity_score >= 0.8 or ( 327 leaf_similarity_score >= adjusted_t 328 and self._dice_coefficient(source_node, target_node) >= self.f 329 ): 330 matching_set.add((source_node_id, target_node_id)) 331 self._unmatched_source_nodes.remove(source_node_id) 332 self._unmatched_target_nodes.remove(target_node_id) 333 ordered_unmatched_target_nodes.pop(target_node_id, None) 334 target_leaf_ids_cache.pop(target_node_id, None) 335 break 336 337 return matching_set 338 339 def _compute_leaf_matching_set(self) -> set[tuple[int, int]]: 340 candidate_matchings: list[tuple[float, int, int, exp.Expr, exp.Expr]] = [] 341 source_expression_leaves = list(_get_expression_leaves(self._source)) 342 target_expression_leaves = list(_get_expression_leaves(self._target)) 343 for source_leaf in source_expression_leaves: 344 for target_leaf in target_expression_leaves: 345 if _is_same_type(source_leaf, target_leaf): 346 similarity_score = self._dice_coefficient(source_leaf, target_leaf) 347 if similarity_score >= self.f: 348 heappush( 349 candidate_matchings, 350 ( 351 -similarity_score, 352 -_parent_similarity_score(source_leaf, target_leaf), 353 len(candidate_matchings), 354 source_leaf, 355 target_leaf, 356 ), 357 ) 358 359 # Pick best matchings based on the highest score 360 matching_set = set() 361 while candidate_matchings: 362 _, _, _, source_leaf, target_leaf = heappop(candidate_matchings) 363 if ( 364 id(source_leaf) in self._unmatched_source_nodes 365 and id(target_leaf) in self._unmatched_target_nodes 366 ): 367 matching_set.add((id(source_leaf), id(target_leaf))) 368 self._unmatched_source_nodes.remove(id(source_leaf)) 369 self._unmatched_target_nodes.remove(id(target_leaf)) 370 371 return matching_set 372 373 def _dice_coefficient(self, source: exp.Expr, target: exp.Expr) -> float: 374 source_histo = self._bigram_histo(source) 375 target_histo = self._bigram_histo(target) 376 377 total_grams = sum(source_histo.values()) + sum(target_histo.values()) 378 if not total_grams: 379 return 1.0 if source == target else 0.0 380 381 overlap_len = 0 382 overlapping_grams = set(source_histo) & set(target_histo) 383 for g in overlapping_grams: 384 overlap_len += min(source_histo[g], target_histo[g]) 385 386 return 2 * overlap_len / total_grams 387 388 def _bigram_histo(self, expression: exp.Expr) -> defaultdict[str, int]: 389 if id(expression) in self._bigram_histo_cache: 390 return self._bigram_histo_cache[id(expression)] 391 392 expression_str = self._sql_generator.generate(expression) 393 count = max(0, len(expression_str) - 1) 394 bigram_histo: defaultdict[str, int] = defaultdict(int) 395 for i in range(count): 396 bigram_histo[expression_str[i : i + 2]] += 1 397 398 self._bigram_histo_cache[id(expression)] = bigram_histo 399 return bigram_histo 400 401 402def _get_expression_leaves(expression: exp.Expr) -> Iterator[exp.Expr]: 403 has_child_exprs = False 404 405 for node in expression.iter_expressions(): 406 if not isinstance(node, IGNORED_LEAF_EXPRESSION_TYPES): 407 has_child_exprs = True 408 yield from _get_expression_leaves(node) 409 410 if not has_child_exprs: 411 yield expression 412 413 414def _get_non_expression_leaves(expression: exp.Expr) -> Iterator[tuple[str, t.Any]]: 415 for arg, value in expression.args.items(): 416 if ( 417 value is None 418 or isinstance(value, exp.Expr) 419 or (isinstance(value, list) and isinstance(seq_get(value, 0), exp.Expr)) 420 ): 421 continue 422 423 yield (arg, value) 424 425 426def _is_same_type(source: exp.Expr, target: exp.Expr) -> bool: 427 if type(source) is type(target): 428 if isinstance(source, exp.Join): 429 return source.args.get("side") == target.args.get("side") 430 431 if isinstance(source, exp.Anonymous): 432 return source.this == target.this 433 434 return True 435 436 return False 437 438 439def _parent_similarity_score(source: exp.Expr | None, target: exp.Expr | None) -> int: 440 if source is None or target is None or type(source) is not type(target): 441 return 0 442 443 return 1 + _parent_similarity_score(source.parent, target.parent) 444 445 446def _expression_only_args(expression: exp.Expr) -> Iterator[exp.Expr]: 447 yield from ( 448 arg 449 for arg in expression.iter_expressions() 450 if not isinstance(arg, IGNORED_LEAF_EXPRESSION_TYPES) 451 ) 452 453 454def _lcs( 455 seq_a: Sequence[T], seq_b: Sequence[T], equal: t.Callable[[T, T], bool] 456) -> Sequence[T | None]: 457 """Calculates the longest common subsequence""" 458 459 len_a = len(seq_a) 460 len_b = len(seq_b) 461 lcs_result = [[None] * (len_b + 1) for i in range(len_a + 1)] 462 463 for i in range(len_a + 1): 464 for j in range(len_b + 1): 465 if i == 0 or j == 0: 466 lcs_result[i][j] = [] # type: ignore 467 elif equal(seq_a[i - 1], seq_b[j - 1]): 468 lcs_result[i][j] = lcs_result[i - 1][j - 1] + [seq_a[i - 1]] # type: ignore 469 else: 470 lcs_result[i][j] = ( 471 lcs_result[i - 1][j] 472 if len(lcs_result[i - 1][j]) > len(lcs_result[i][j - 1]) # type: ignore 473 else lcs_result[i][j - 1] 474 ) 475 476 return lcs_result[len_a][len_b] # type: ignore
24@dataclass(frozen=True) 25class Insert: 26 """Indicates that a new node has been inserted""" 27 28 expression: exp.Expr
Indicates that a new node has been inserted
31@dataclass(frozen=True) 32class Remove: 33 """Indicates that an existing node has been removed""" 34 35 expression: exp.Expr
Indicates that an existing node has been removed
38@dataclass(frozen=True) 39class Move: 40 """Indicates that an existing node's position within the tree has changed""" 41 42 source: exp.Expr 43 target: exp.Expr
Indicates that an existing node's position within the tree has changed
46@dataclass(frozen=True) 47class Update: 48 """Indicates that an existing node has been updated""" 49 50 source: exp.Expr 51 target: exp.Expr
Indicates that an existing node has been updated
54@dataclass(frozen=True) 55class Keep: 56 """Indicates that an existing node hasn't been changed""" 57 58 source: exp.Expr 59 target: exp.Expr
Indicates that an existing node hasn't been changed
68def diff( 69 source: exp.Expr, 70 target: exp.Expr, 71 matchings: list[tuple[exp.Expr, exp.Expr]] | None = None, 72 delta_only: bool = False, 73 **kwargs: t.Any, 74) -> list[Edit]: 75 """ 76 Returns the list of changes between the source and the target expressions. 77 78 Examples: 79 >>> from sqlglot import parse_one 80 >>> diff(parse_one("a + b"), parse_one("a + c")) # doctest: +SKIP 81 [...] 82 83 Args: 84 source: the source expression. 85 target: the target expression against which the diff should be calculated. 86 matchings: the list of pre-matched node pairs which is used to help the algorithm's 87 heuristics produce better results for subtrees that are known by a caller to be matching. 88 Note: expression references in this list must refer to the same node objects that are 89 referenced in the source / target trees. 90 delta_only: excludes all `Keep` nodes from the diff. 91 kwargs: additional arguments to pass to the ChangeDistiller instance. 92 93 Returns: 94 the list of Insert, Remove, Move, Update and Keep objects for each node in the source and the 95 target expression trees. This list represents a sequence of steps needed to transform the source 96 expression tree into the target one. 97 """ 98 matchings = matchings or [] 99 100 def compute_node_mappings( 101 old_nodes: tuple[exp.Expr, ...], new_nodes: tuple[exp.Expr, ...] 102 ) -> dict[int, exp.Expr]: 103 node_mapping = {} 104 for old_node, new_node in zip(reversed(old_nodes), reversed(new_nodes)): 105 new_node._hash = hash(new_node) 106 node_mapping[id(old_node)] = new_node 107 108 return node_mapping 109 110 # if the source and target have any shared objects, that means there's an issue with the ast 111 # the algorithm won't work because the parent / hierarchies will be inaccurate 112 source_nodes = tuple(source.walk()) 113 target_nodes = tuple(target.walk()) 114 source_ids = {id(n) for n in source_nodes} 115 target_ids = {id(n) for n in target_nodes} 116 117 copy = ( 118 len(source_nodes) != len(source_ids) 119 or len(target_nodes) != len(target_ids) 120 or source_ids & target_ids 121 ) 122 123 source_copy = source.copy() if copy else source 124 target_copy = target.copy() if copy else target 125 126 try: 127 # We cache the hash of each new node here to speed up equality comparisons. If the input 128 # trees aren't copied, these hashes will be evicted before returning the edit script. 129 if copy and matchings: 130 source_mapping = compute_node_mappings(source_nodes, tuple(source_copy.walk())) 131 target_mapping = compute_node_mappings(target_nodes, tuple(target_copy.walk())) 132 matchings = [(source_mapping[id(s)], target_mapping[id(t)]) for s, t in matchings] 133 else: 134 for node in chain(reversed(source_nodes), reversed(target_nodes)): 135 node._hash = hash(node) 136 137 edit_script = ChangeDistiller(**kwargs).diff( 138 source_copy, 139 target_copy, 140 matchings=matchings, 141 delta_only=delta_only, 142 ) 143 finally: 144 if not copy: 145 for node in chain(source_nodes, target_nodes): 146 node._hash = None 147 148 return edit_script
Returns the list of changes between the source and the target expressions.
Examples:
>>> from sqlglot import parse_one >>> diff(parse_one("a + b"), parse_one("a + c")) # doctest: +SKIP [...]
Arguments:
- source: the source expression.
- target: the target expression against which the diff should be calculated.
- matchings: the list of pre-matched node pairs which is used to help the algorithm's heuristics produce better results for subtrees that are known by a caller to be matching. Note: expression references in this list must refer to the same node objects that are referenced in the source / target trees.
- delta_only: excludes all
Keepnodes from the diff. - kwargs: additional arguments to pass to the ChangeDistiller instance.
Returns:
the list of Insert, Remove, Move, Update and Keep objects for each node in the source and the target expression trees. This list represents a sequence of steps needed to transform the source expression tree into the target one.
166class ChangeDistiller: 167 """ 168 The implementation of the Change Distiller algorithm described by Beat Fluri and Martin Pinzger in 169 their paper https://ieeexplore.ieee.org/document/4339230, which in turn is based on the algorithm by 170 Chawathe et al. described in http://ilpubs.stanford.edu:8090/115/1/1995-46.pdf. 171 """ 172 173 def __init__(self, f: float = 0.6, t: float = 0.6, dialect: DialectType = None) -> None: 174 self.f = f 175 self.t = t 176 self._sql_generator = Dialect.get_or_raise(dialect).generator(comments=False) 177 178 def diff( 179 self, 180 source: exp.Expr, 181 target: exp.Expr, 182 matchings: list[tuple[exp.Expr, exp.Expr]] | None = None, 183 delta_only: bool = False, 184 ) -> list[Edit]: 185 matchings = matchings or [] 186 pre_matched_nodes = {id(s): id(t) for s, t in matchings} 187 188 self._source = source 189 self._target = target 190 self._source_index = { 191 id(n): n for n in self._source.bfs() if not isinstance(n, IGNORED_LEAF_EXPRESSION_TYPES) 192 } 193 self._target_index = { 194 id(n): n for n in self._target.bfs() if not isinstance(n, IGNORED_LEAF_EXPRESSION_TYPES) 195 } 196 self._unmatched_source_nodes = set(self._source_index) - set(pre_matched_nodes) 197 self._unmatched_target_nodes = set(self._target_index) - set(pre_matched_nodes.values()) 198 self._bigram_histo_cache: dict[int, defaultdict[str, int]] = {} 199 200 matching_set = self._compute_matching_set() | set(pre_matched_nodes.items()) 201 return self._generate_edit_script(dict(matching_set), delta_only) 202 203 def _generate_edit_script(self, matchings: dict[int, int], delta_only: bool) -> list[Edit]: 204 edit_script: list[Edit] = [] 205 206 # The unmatched node sets and the matching set are keyed by id(), so iterating them 207 # directly orders the edits by object address, which varies between processes. The 208 # node indexes are built from bfs(), so iterating those instead keeps the edit script 209 # in a stable, tree-order sequence. 210 for removed_node_id in self._source_index: 211 if removed_node_id in self._unmatched_source_nodes: 212 edit_script.append(Remove(self._source_index[removed_node_id])) 213 for inserted_node_id in self._target_index: 214 if inserted_node_id in self._unmatched_target_nodes: 215 edit_script.append(Insert(self._target_index[inserted_node_id])) 216 for kept_source_node_id in self._source_index: 217 kept_target_node_id = matchings.get(kept_source_node_id) 218 if kept_target_node_id is None: 219 continue 220 221 source_node = self._source_index[kept_source_node_id] 222 target_node = self._target_index[kept_target_node_id] 223 224 identical_nodes = source_node == target_node 225 226 if not isinstance(source_node, UPDATABLE_EXPRESSION_TYPES) or identical_nodes: 227 if identical_nodes: 228 source_parent = source_node.parent 229 target_parent = target_node.parent 230 231 if ( 232 (source_parent and not target_parent) 233 or (not source_parent and target_parent) 234 or ( 235 source_parent 236 and target_parent 237 and matchings.get(id(source_parent)) != id(target_parent) 238 ) 239 ): 240 edit_script.append(Move(source=source_node, target=target_node)) 241 else: 242 edit_script.extend( 243 self._generate_move_edits(source_node, target_node, matchings) 244 ) 245 246 source_non_expression_leaves = dict(_get_non_expression_leaves(source_node)) 247 target_non_expression_leaves = dict(_get_non_expression_leaves(target_node)) 248 249 if source_non_expression_leaves != target_non_expression_leaves: 250 edit_script.append(Update(source_node, target_node)) 251 elif not delta_only: 252 edit_script.append(Keep(source_node, target_node)) 253 else: 254 edit_script.append(Update(source_node, target_node)) 255 256 return edit_script 257 258 def _generate_move_edits( 259 self, source: exp.Expr, target: exp.Expr, matchings: dict[int, int] 260 ) -> list[Move]: 261 source_args = [id(e) for e in _expression_only_args(source)] 262 target_args = [id(e) for e in _expression_only_args(target)] 263 264 args_lcs = set( 265 _lcs(source_args, target_args, lambda l, r: matchings.get(t.cast(int, l)) == r) 266 ) 267 268 move_edits = [] 269 for a in source_args: 270 if a not in args_lcs and a not in self._unmatched_source_nodes: 271 move_edits.append( 272 Move(source=self._source_index[a], target=self._target_index[matchings[a]]) 273 ) 274 275 return move_edits 276 277 def _compute_matching_set(self) -> set[tuple[int, int]]: 278 leaves_matching_set = self._compute_leaf_matching_set() 279 matching_set = leaves_matching_set.copy() 280 281 matched_target_leaves: dict[int, list[int]] = defaultdict(list) 282 for source_leaf_id, target_leaf_id in leaves_matching_set: 283 matched_target_leaves[source_leaf_id].append(target_leaf_id) 284 285 ordered_unmatched_source_nodes = { 286 id(n): None for n in self._source.bfs() if id(n) in self._unmatched_source_nodes 287 } 288 ordered_unmatched_target_nodes = { 289 id(n): None for n in self._target.bfs() if id(n) in self._unmatched_target_nodes 290 } 291 292 target_leaf_ids_cache: dict[int, set[int]] = {} 293 294 for source_node_id in ordered_unmatched_source_nodes: 295 source_node = self._source_index[source_node_id] 296 source_leaf_ids = None 297 298 for target_node_id in ordered_unmatched_target_nodes: 299 target_node = self._target_index[target_node_id] 300 if _is_same_type(source_node, target_node): 301 if source_leaf_ids is None: 302 source_leaf_ids = {id(l) for l in _get_expression_leaves(source_node)} 303 source_leaves_num = len(source_leaf_ids) 304 candidate_target_leaf_ids = [ 305 target_leaf_id 306 for source_leaf_id in source_leaf_ids 307 for target_leaf_id in matched_target_leaves.get(source_leaf_id, ()) 308 ] 309 310 target_leaf_ids = target_leaf_ids_cache.get(target_node_id) 311 if target_leaf_ids is None: 312 target_leaf_ids = {id(l) for l in _get_expression_leaves(target_node)} 313 target_leaf_ids_cache[target_node_id] = target_leaf_ids 314 315 target_leaves_num = len(target_leaf_ids) 316 max_leaves_num = max(source_leaves_num, target_leaves_num) 317 if max_leaves_num: 318 common_leaves_num = sum( 319 1 for t in candidate_target_leaf_ids if t in target_leaf_ids 320 ) 321 leaf_similarity_score = common_leaves_num / max_leaves_num 322 else: 323 leaf_similarity_score = 0.0 324 325 adjusted_t = self.t if min(source_leaves_num, target_leaves_num) > 4 else 0.4 326 327 if leaf_similarity_score >= 0.8 or ( 328 leaf_similarity_score >= adjusted_t 329 and self._dice_coefficient(source_node, target_node) >= self.f 330 ): 331 matching_set.add((source_node_id, target_node_id)) 332 self._unmatched_source_nodes.remove(source_node_id) 333 self._unmatched_target_nodes.remove(target_node_id) 334 ordered_unmatched_target_nodes.pop(target_node_id, None) 335 target_leaf_ids_cache.pop(target_node_id, None) 336 break 337 338 return matching_set 339 340 def _compute_leaf_matching_set(self) -> set[tuple[int, int]]: 341 candidate_matchings: list[tuple[float, int, int, exp.Expr, exp.Expr]] = [] 342 source_expression_leaves = list(_get_expression_leaves(self._source)) 343 target_expression_leaves = list(_get_expression_leaves(self._target)) 344 for source_leaf in source_expression_leaves: 345 for target_leaf in target_expression_leaves: 346 if _is_same_type(source_leaf, target_leaf): 347 similarity_score = self._dice_coefficient(source_leaf, target_leaf) 348 if similarity_score >= self.f: 349 heappush( 350 candidate_matchings, 351 ( 352 -similarity_score, 353 -_parent_similarity_score(source_leaf, target_leaf), 354 len(candidate_matchings), 355 source_leaf, 356 target_leaf, 357 ), 358 ) 359 360 # Pick best matchings based on the highest score 361 matching_set = set() 362 while candidate_matchings: 363 _, _, _, source_leaf, target_leaf = heappop(candidate_matchings) 364 if ( 365 id(source_leaf) in self._unmatched_source_nodes 366 and id(target_leaf) in self._unmatched_target_nodes 367 ): 368 matching_set.add((id(source_leaf), id(target_leaf))) 369 self._unmatched_source_nodes.remove(id(source_leaf)) 370 self._unmatched_target_nodes.remove(id(target_leaf)) 371 372 return matching_set 373 374 def _dice_coefficient(self, source: exp.Expr, target: exp.Expr) -> float: 375 source_histo = self._bigram_histo(source) 376 target_histo = self._bigram_histo(target) 377 378 total_grams = sum(source_histo.values()) + sum(target_histo.values()) 379 if not total_grams: 380 return 1.0 if source == target else 0.0 381 382 overlap_len = 0 383 overlapping_grams = set(source_histo) & set(target_histo) 384 for g in overlapping_grams: 385 overlap_len += min(source_histo[g], target_histo[g]) 386 387 return 2 * overlap_len / total_grams 388 389 def _bigram_histo(self, expression: exp.Expr) -> defaultdict[str, int]: 390 if id(expression) in self._bigram_histo_cache: 391 return self._bigram_histo_cache[id(expression)] 392 393 expression_str = self._sql_generator.generate(expression) 394 count = max(0, len(expression_str) - 1) 395 bigram_histo: defaultdict[str, int] = defaultdict(int) 396 for i in range(count): 397 bigram_histo[expression_str[i : i + 2]] += 1 398 399 self._bigram_histo_cache[id(expression)] = bigram_histo 400 return bigram_histo
The implementation of the Change Distiller algorithm described by Beat Fluri and Martin Pinzger in their paper https://ieeexplore.ieee.org/document/4339230, which in turn is based on the algorithm by Chawathe et al. described in http://ilpubs.stanford.edu:8090/115/1/1995-46.pdf.
178 def diff( 179 self, 180 source: exp.Expr, 181 target: exp.Expr, 182 matchings: list[tuple[exp.Expr, exp.Expr]] | None = None, 183 delta_only: bool = False, 184 ) -> list[Edit]: 185 matchings = matchings or [] 186 pre_matched_nodes = {id(s): id(t) for s, t in matchings} 187 188 self._source = source 189 self._target = target 190 self._source_index = { 191 id(n): n for n in self._source.bfs() if not isinstance(n, IGNORED_LEAF_EXPRESSION_TYPES) 192 } 193 self._target_index = { 194 id(n): n for n in self._target.bfs() if not isinstance(n, IGNORED_LEAF_EXPRESSION_TYPES) 195 } 196 self._unmatched_source_nodes = set(self._source_index) - set(pre_matched_nodes) 197 self._unmatched_target_nodes = set(self._target_index) - set(pre_matched_nodes.values()) 198 self._bigram_histo_cache: dict[int, defaultdict[str, int]] = {} 199 200 matching_set = self._compute_matching_set() | set(pre_matched_nodes.items()) 201 return self._generate_edit_script(dict(matching_set), delta_only)