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