Edit on GitHub

sqlglot.anonymize

  1from __future__ import annotations
  2
  3import string
  4
  5from sqlglot.dialects.dialect import Dialect, DialectType
  6from sqlglot.errors import TokenError
  7from sqlglot.tokens import Token, TokenType
  8
  9
 10ALPHABET = string.ascii_lowercase
 11ALPHABET_SIZE = len(ALPHABET)
 12ANONYMIZED_TYPES = {
 13    TokenType.BIT_STRING,
 14    TokenType.BYTE_STRING,
 15    TokenType.HEX_STRING,
 16    TokenType.HEREDOC_STRING,
 17    TokenType.IDENTIFIER,
 18    TokenType.NATIONAL_STRING,
 19    TokenType.NUMBER,
 20    TokenType.RAW_STRING,
 21    TokenType.STRING,
 22    TokenType.UNICODE_STRING,
 23    TokenType.VAR,
 24}
 25QUOTED_TYPES = ANONYMIZED_TYPES - {TokenType.NUMBER, TokenType.VAR}
 26REWRITTEN_TYPES = {TokenType.HINT, TokenType.UNKNOWN}
 27
 28
 29def anonymize(
 30    sql_or_tokens: list[Token] | str,
 31    dialect: DialectType = None,
 32) -> list[Token]:
 33    """Replaces sensitive tokens (identifiers, strings, numbers) with fixed-width,
 34    length-preserving, consistent aliases, and blanks out comments and hint bodies. When a
 35    SQL string is given, it is tokenized with `dialect` first; any un-tokenized remainder
 36    (e.g. an unterminated literal) is appended as a blanked UNKNOWN token. Mutates and
 37    returns `sql_or_tokens`.
 38
 39    Args:
 40        sql_or_tokens: The SQL string to anonymize, or its token list.
 41        dialect: The dialect used to tokenize a SQL string.
 42    """
 43    dialect = Dialect.get_or_raise(dialect)
 44    tokenizer_class = dialect.tokenizer_class
 45    parser_class = dialect.parser_class
 46
 47    errored = False
 48    if isinstance(sql_or_tokens, str):
 49        sql = sql_or_tokens
 50        tokenizer = dialect.tokenizer()
 51        try:
 52            tokens = tokenizer.tokenize(sql)
 53        except TokenError:
 54            tokens = tokenizer.tokens
 55            errored = True
 56    else:
 57        tokens = sql_or_tokens
 58        sql = None
 59
 60    hint_start = tokenizer_class.HINT_START
 61    hint_end = tokenizer_class._COMMENTS.get(hint_start)
 62    nested = tokenizer_class.NESTED_COMMENTS
 63
 64    seen: dict[tuple[bool, str], str] = {}
 65    counter = 0
 66
 67    for i, token in enumerate(tokens):
 68        token.comments = [_blank(comment) for comment in token.comments]
 69
 70        if token.token_type == TokenType.HINT:
 71            # A hint's text is the whole /*+ ... */ comment, so its body is blanked as well
 72            text, stop = _blank_comment(token.text, 0, hint_start, hint_end, nested)
 73            token.text = text + _blank(token.text[stop:])
 74            continue
 75
 76        if (
 77            token.token_type == TokenType.VAR
 78            and i + 1 < len(tokens)
 79            and tokens[i + 1].token_type == TokenType.L_PAREN
 80        ):
 81            # A function name can live in either registry, e.g. JSON_OBJECT is only in
 82            # FUNCTION_PARSERS. They're consulted separately to avoid building their union
 83            name = token.text.upper()
 84            if name in parser_class.FUNCTIONS or name in parser_class.FUNCTION_PARSERS:
 85                continue
 86        if token.token_type not in ANONYMIZED_TYPES:
 87            continue
 88        if not token.text:
 89            continue
 90
 91        is_number = token.token_type == TokenType.NUMBER
 92        key = (is_number, token.text)
 93        alias = seen.get(key)
 94        if alias is None:
 95            seen[key] = (
 96                _number_alias(counter, token.text) if is_number else _alias(counter, token.text)
 97            )
 98            counter += 1
 99        token.text = seen[key]
100
101    if sql is not None and errored:
102        start = tokens[-1].end + 1 if tokens else 0
103        length = len(sql)
104        while start < length and sql[start].isspace():
105            start += 1
106        if start < length:
107            # The first two characters are kept so that the delimiter the tokenizer choked
108            # on is still visible, e.g. 'u, /*, $$, `u
109            tokens.append(
110                Token(
111                    TokenType.UNKNOWN,
112                    sql[start : start + 2] + "." * (length - start - 2),
113                    start=start,
114                    end=length - 1,
115                    line=sql.count("\n", 0, start) + 1,
116                    col=start - sql.rfind("\n", 0, start),
117                )
118            )
119
120    return tokens
121
122
123def render(sql: str, tokens: list[Token], dialect: DialectType = None) -> str:
124    """Recreates the (anonymized) SQL string from the original `sql` and token positions.
125
126    Every token is rendered over its own source span, so the result is always as long as
127    `sql`: a token that wasn't anonymized is emitted verbatim, keeping the spelling the
128    tokenizer normalized away, and an anonymized one has its alias fitted between the
129    quotes of its span. The gaps between tokens hold only whitespace and comments, so
130    they're redacted rather than reconstructed: comment markers and whitespace survive,
131    everything else is blanked. That covers anything the tokenizer didn't reach as well,
132    which is a trailing gap whenever `anonymize` wasn't the one to tokenize `sql`.
133
134    Args:
135        sql: The original SQL string.
136        tokens: The anonymized tokens to render.
137        dialect: The dialect used to identify comments and quotes in `sql`.
138    """
139    tokenizer_class = Dialect.get_or_raise(dialect).tokenizer_class
140    comments = sorted(tokenizer_class._COMMENTS.items(), key=lambda c: len(c[0]), reverse=True)
141    nested = tokenizer_class.NESTED_COMMENTS
142    quotes = sorted(
143        {
144            **tokenizer_class._QUOTES,
145            **{start: end for start, (end, _) in tokenizer_class._FORMAT_STRINGS.items()},
146            **tokenizer_class._IDENTIFIERS,
147        }.items(),
148        key=lambda quote: (len(quote[0]), len(quote[1])),
149        reverse=True,
150    )
151
152    result = []
153    prev = 0
154
155    for token in tokens:
156        result.append(_redact(sql[prev : token.start], comments, nested))
157        prev = token.end + 1
158        span = sql[token.start : prev]
159        if token.token_type in REWRITTEN_TYPES:
160            # Blanked in place by `anonymize`, or synthesized by it, so already span-shaped
161            result.append(token.text)
162        elif token.token_type in ANONYMIZED_TYPES:
163            result.append(_fit(span, token.text, quotes, token.token_type))
164        else:
165            result.append(span)
166
167    result.append(_redact(sql[prev:], comments, nested))
168
169    return "".join(result)
170
171
172def _alias(counter: int, text: str) -> str:
173    digits = []
174    while counter:
175        counter, digit = divmod(counter, ALPHABET_SIZE)
176        digits.append(ALPHABET[digit])
177
178    letters = "".join(reversed(digits)).rjust(sum(not char.isspace() for char in text), "a")
179
180    alias = []
181    i = 0
182    for char in text:
183        if char.isspace():
184            alias.append(char)
185        else:
186            alias.append(letters[i])
187            i += 1
188
189    return "".join(alias)
190
191
192def _number_alias(counter: int, text: str) -> str:
193    if len(text) > 4000:
194        digits_seen = False
195        blanked = []
196        for char in text:
197            if char.isdigit():
198                blanked.append("0" if digits_seen else "1")
199                digits_seen = True
200            else:
201                blanked.append(char)
202        return "".join(blanked)
203
204    sep = "e" if "e" in text else ("E" if "E" in text else "")
205    mantissa, _, exponent = text.partition(sep) if sep else (text, "", "")
206    sign = ""
207    if exponent.startswith(("-", "+")):
208        sign, exponent = exponent[0], exponent[1:]
209    exponent_length = len(exponent)
210
211    integer, dot, fraction = mantissa.partition(".")
212    integer_length = len(integer)
213    digits = integer_length + len(fraction)
214    mantissa_value = 10 ** (digits - 1) + counter % (9 * 10 ** (digits - 1))
215    result = str(mantissa_value)
216    if dot:
217        result = result[:integer_length] + "." + result[integer_length:]
218    if sep:
219        # The exponent marker is kept even when there are no digits after it, e.g. 1e
220        result += sep + sign
221        if exponent_length:
222            exponent_value = 10 ** (exponent_length - 1) + (counter // (9 * 10 ** (digits - 1))) % (
223                9 * 10 ** (exponent_length - 1)
224            )
225            result += str(exponent_value)
226
227    return result
228
229
230def _fit(span: str, alias: str, quotes: list[tuple[str, str]], token_type: TokenType) -> str:
231    """Fits `alias` into the quoted region of `span`, so the two are always the same length."""
232    start = end = 0
233
234    if token_type in QUOTED_TYPES:
235        for open_quote, close_quote in quotes:
236            if (
237                len(span) >= len(open_quote) + len(close_quote)
238                and span.startswith(open_quote)
239                and span.endswith(close_quote)
240            ):
241                start, end = len(open_quote), len(close_quote)
242
243                if token_type == TokenType.HEREDOC_STRING:
244                    # A heredoc's tag is part of its delimiter, e.g. $tag$body$tag$
245                    open_end = span.find(close_quote, start)
246                    close_start = span.rfind(open_quote, 0, len(span) - end)
247                    if start <= open_end < close_start:
248                        start, end = open_end + len(close_quote), len(span) - close_start
249
250                break
251
252    width = len(span) - start - end
253    pad = "0" if token_type == TokenType.NUMBER else "a"
254
255    return span[:start] + alias[:width].rjust(width, pad) + span[len(span) - end :]
256
257
258def _blank(sql: str) -> str:
259    return "".join(char if char.isspace() else "." for char in sql)
260
261
262def _blank_comment(sql: str, i: int, start: str, end: str | None, nested: bool) -> tuple[str, int]:
263    """Blanks the body of the comment at `i`, returning its text and the index past it."""
264    body = i + len(start)
265
266    if not end:
267        stop = sql.find("\n", body)
268        stop = len(sql) if stop == -1 else stop
269        return start + _blank(sql[body:stop]), stop
270
271    depth = 1
272    j = body
273    while j < len(sql):
274        if nested and sql.startswith(start, j):
275            depth += 1
276            j += len(start)
277        elif sql.startswith(end, j):
278            j += len(end)
279            depth -= 1
280            if not depth:
281                return start + _blank(sql[body : j - len(end)]) + end, j
282        else:
283            j += 1
284
285    return start + _blank(sql[body:]), len(sql)
286
287
288def _redact(sql: str, comments: list[tuple[str, str | None]], nested: bool) -> str:
289    if not sql or sql.isspace():
290        return sql
291
292    result = []
293    i = 0
294    length = len(sql)
295
296    while i < length:
297        for start, end in comments:
298            if sql.startswith(start, i):
299                text, i = _blank_comment(sql, i, start, end, nested)
300                result.append(text)
301                break
302        else:
303            char = sql[i]
304            result.append(char if char.isspace() else ".")
305            i += 1
306
307    return "".join(result)
ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
ALPHABET_SIZE = 26
ANONYMIZED_TYPES = {<TokenType.UNICODE_STRING: 96>, <TokenType.STRING: 77>, <TokenType.NUMBER: 78>, <TokenType.IDENTIFIER: 79>, <TokenType.VAR: 89>, <TokenType.BIT_STRING: 90>, <TokenType.HEX_STRING: 91>, <TokenType.BYTE_STRING: 92>, <TokenType.NATIONAL_STRING: 93>, <TokenType.RAW_STRING: 94>, <TokenType.HEREDOC_STRING: 95>}
QUOTED_TYPES = {<TokenType.UNICODE_STRING: 96>, <TokenType.STRING: 77>, <TokenType.IDENTIFIER: 79>, <TokenType.BIT_STRING: 90>, <TokenType.HEX_STRING: 91>, <TokenType.BYTE_STRING: 92>, <TokenType.NATIONAL_STRING: 93>, <TokenType.RAW_STRING: 94>, <TokenType.HEREDOC_STRING: 95>}
REWRITTEN_TYPES = {<TokenType.HINT: 293>, <TokenType.UNKNOWN: 214>}
def anonymize( sql_or_tokens: list[sqlglot.tokenizer_core.Token] | str, dialect: Union[str, sqlglot.dialects.Dialect, type[sqlglot.dialects.Dialect], NoneType] = None) -> list[sqlglot.tokenizer_core.Token]:
 30def anonymize(
 31    sql_or_tokens: list[Token] | str,
 32    dialect: DialectType = None,
 33) -> list[Token]:
 34    """Replaces sensitive tokens (identifiers, strings, numbers) with fixed-width,
 35    length-preserving, consistent aliases, and blanks out comments and hint bodies. When a
 36    SQL string is given, it is tokenized with `dialect` first; any un-tokenized remainder
 37    (e.g. an unterminated literal) is appended as a blanked UNKNOWN token. Mutates and
 38    returns `sql_or_tokens`.
 39
 40    Args:
 41        sql_or_tokens: The SQL string to anonymize, or its token list.
 42        dialect: The dialect used to tokenize a SQL string.
 43    """
 44    dialect = Dialect.get_or_raise(dialect)
 45    tokenizer_class = dialect.tokenizer_class
 46    parser_class = dialect.parser_class
 47
 48    errored = False
 49    if isinstance(sql_or_tokens, str):
 50        sql = sql_or_tokens
 51        tokenizer = dialect.tokenizer()
 52        try:
 53            tokens = tokenizer.tokenize(sql)
 54        except TokenError:
 55            tokens = tokenizer.tokens
 56            errored = True
 57    else:
 58        tokens = sql_or_tokens
 59        sql = None
 60
 61    hint_start = tokenizer_class.HINT_START
 62    hint_end = tokenizer_class._COMMENTS.get(hint_start)
 63    nested = tokenizer_class.NESTED_COMMENTS
 64
 65    seen: dict[tuple[bool, str], str] = {}
 66    counter = 0
 67
 68    for i, token in enumerate(tokens):
 69        token.comments = [_blank(comment) for comment in token.comments]
 70
 71        if token.token_type == TokenType.HINT:
 72            # A hint's text is the whole /*+ ... */ comment, so its body is blanked as well
 73            text, stop = _blank_comment(token.text, 0, hint_start, hint_end, nested)
 74            token.text = text + _blank(token.text[stop:])
 75            continue
 76
 77        if (
 78            token.token_type == TokenType.VAR
 79            and i + 1 < len(tokens)
 80            and tokens[i + 1].token_type == TokenType.L_PAREN
 81        ):
 82            # A function name can live in either registry, e.g. JSON_OBJECT is only in
 83            # FUNCTION_PARSERS. They're consulted separately to avoid building their union
 84            name = token.text.upper()
 85            if name in parser_class.FUNCTIONS or name in parser_class.FUNCTION_PARSERS:
 86                continue
 87        if token.token_type not in ANONYMIZED_TYPES:
 88            continue
 89        if not token.text:
 90            continue
 91
 92        is_number = token.token_type == TokenType.NUMBER
 93        key = (is_number, token.text)
 94        alias = seen.get(key)
 95        if alias is None:
 96            seen[key] = (
 97                _number_alias(counter, token.text) if is_number else _alias(counter, token.text)
 98            )
 99            counter += 1
100        token.text = seen[key]
101
102    if sql is not None and errored:
103        start = tokens[-1].end + 1 if tokens else 0
104        length = len(sql)
105        while start < length and sql[start].isspace():
106            start += 1
107        if start < length:
108            # The first two characters are kept so that the delimiter the tokenizer choked
109            # on is still visible, e.g. 'u, /*, $$, `u
110            tokens.append(
111                Token(
112                    TokenType.UNKNOWN,
113                    sql[start : start + 2] + "." * (length - start - 2),
114                    start=start,
115                    end=length - 1,
116                    line=sql.count("\n", 0, start) + 1,
117                    col=start - sql.rfind("\n", 0, start),
118                )
119            )
120
121    return tokens

Replaces sensitive tokens (identifiers, strings, numbers) with fixed-width, length-preserving, consistent aliases, and blanks out comments and hint bodies. When a SQL string is given, it is tokenized with dialect first; any un-tokenized remainder (e.g. an unterminated literal) is appended as a blanked UNKNOWN token. Mutates and returns sql_or_tokens.

Arguments:
  • sql_or_tokens: The SQL string to anonymize, or its token list.
  • dialect: The dialect used to tokenize a SQL string.
def render( sql: str, tokens: list[sqlglot.tokenizer_core.Token], dialect: Union[str, sqlglot.dialects.Dialect, type[sqlglot.dialects.Dialect], NoneType] = None) -> str:
124def render(sql: str, tokens: list[Token], dialect: DialectType = None) -> str:
125    """Recreates the (anonymized) SQL string from the original `sql` and token positions.
126
127    Every token is rendered over its own source span, so the result is always as long as
128    `sql`: a token that wasn't anonymized is emitted verbatim, keeping the spelling the
129    tokenizer normalized away, and an anonymized one has its alias fitted between the
130    quotes of its span. The gaps between tokens hold only whitespace and comments, so
131    they're redacted rather than reconstructed: comment markers and whitespace survive,
132    everything else is blanked. That covers anything the tokenizer didn't reach as well,
133    which is a trailing gap whenever `anonymize` wasn't the one to tokenize `sql`.
134
135    Args:
136        sql: The original SQL string.
137        tokens: The anonymized tokens to render.
138        dialect: The dialect used to identify comments and quotes in `sql`.
139    """
140    tokenizer_class = Dialect.get_or_raise(dialect).tokenizer_class
141    comments = sorted(tokenizer_class._COMMENTS.items(), key=lambda c: len(c[0]), reverse=True)
142    nested = tokenizer_class.NESTED_COMMENTS
143    quotes = sorted(
144        {
145            **tokenizer_class._QUOTES,
146            **{start: end for start, (end, _) in tokenizer_class._FORMAT_STRINGS.items()},
147            **tokenizer_class._IDENTIFIERS,
148        }.items(),
149        key=lambda quote: (len(quote[0]), len(quote[1])),
150        reverse=True,
151    )
152
153    result = []
154    prev = 0
155
156    for token in tokens:
157        result.append(_redact(sql[prev : token.start], comments, nested))
158        prev = token.end + 1
159        span = sql[token.start : prev]
160        if token.token_type in REWRITTEN_TYPES:
161            # Blanked in place by `anonymize`, or synthesized by it, so already span-shaped
162            result.append(token.text)
163        elif token.token_type in ANONYMIZED_TYPES:
164            result.append(_fit(span, token.text, quotes, token.token_type))
165        else:
166            result.append(span)
167
168    result.append(_redact(sql[prev:], comments, nested))
169
170    return "".join(result)

Recreates the (anonymized) SQL string from the original sql and token positions.

Every token is rendered over its own source span, so the result is always as long as sql: a token that wasn't anonymized is emitted verbatim, keeping the spelling the tokenizer normalized away, and an anonymized one has its alias fitted between the quotes of its span. The gaps between tokens hold only whitespace and comments, so they're redacted rather than reconstructed: comment markers and whitespace survive, everything else is blanked. That covers anything the tokenizer didn't reach as well, which is a trailing gap whenever anonymize wasn't the one to tokenize sql.

Arguments:
  • sql: The original SQL string.
  • tokens: The anonymized tokens to render.
  • dialect: The dialect used to identify comments and quotes in sql.