Edit on GitHub

Semantic Diff for SQL

by Iaroslav Zeigerman

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:

Git diff output

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 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: Sub and f. These are the nodes from the target AST which do not have a matching node in the source AST.
  • Removed nodes: Add and d. 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:

  1. 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.
  2. 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. 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:

Dice Coefficient

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:

Matching criteria for leaf nodes

The two nodes are matched if two conditions are met:

  1. The node labels match (in our case labels are just node types).
  2. 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. 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:

Matching criteria for inner nodes

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
@dataclass(frozen=True)
class Insert:
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

Insert(expression: sqlglot.expressions.core.Expr)
@dataclass(frozen=True)
class Remove:
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

Remove(expression: sqlglot.expressions.core.Expr)
@dataclass(frozen=True)
class Move:
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

@dataclass(frozen=True)
class Update:
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

@dataclass(frozen=True)
class Keep:
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

def diff( source: sqlglot.expressions.core.Expr, target: sqlglot.expressions.core.Expr, matchings: list[tuple[sqlglot.expressions.core.Expr, sqlglot.expressions.core.Expr]] | None = None, delta_only: bool = False, **kwargs: Any) -> list[typing.Union[Insert, Remove, Move, Update, Keep]]:
 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 Keep nodes 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.

IGNORED_LEAF_EXPRESSION_TYPES = (<class 'sqlglot.expressions.core.Identifier'>,)
class ChangeDistiller:
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.

ChangeDistiller( f: float = 0.6, t: float = 0.6, dialect: Union[str, sqlglot.dialects.Dialect, type[sqlglot.dialects.Dialect], NoneType] = None)
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)
f
t
def diff( self, source: sqlglot.expressions.core.Expr, target: sqlglot.expressions.core.Expr, matchings: list[tuple[sqlglot.expressions.core.Expr, sqlglot.expressions.core.Expr]] | None = None, delta_only: bool = False) -> list[typing.Union[Insert, Remove, Move, Update, Keep]]:
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)
⌂ Proxy