Repository navigation
feat: sql analysis - #1317
Open
JuanLeee wants to merge 4 commits into
Open
feat: sql analysis#1317JuanLeee wants to merge 4 commits into
JuanLeee wants to merge 4 commits into
Conversation
JuanLeee
force-pushed
the
feat/kms-sql-analysis
branch
from
October 28, 2026 11:54
7b3cb7f to
b82e57a
Compare
JuanLeee
force-pushed
the
feat/encryption-service
branch
from
October 28, 2026 11:54
0333a07 to
a633a43
Compare
This was referenced Oct 28, 2026
Contributor
Author
|
CI doesn't run on this PR while its base is #1300's branch, so here are the local results for
|
JuanLeee
force-pushed
the
feat/kms-sql-analysis
branch
from
October 28, 2026 12:00
b82e57a to
1276122
Compare
JuanLeee
force-pushed
the
feat/encryption-service
branch
from
October 28, 2026 12:00
a633a43 to
0f538c7
Compare
JuanLeee
force-pushed
the
feat/kms-sql-analysis
branch
from
October 29, 2026 12:02
1276122 to
4e4c9f9
Compare
JuanLeee
force-pushed
the
feat/encryption-service
branch
2 times, most recently
from
November 2, 2026 11:01
2296d47 to
9295923
Compare
JuanLeee
force-pushed
the
feat/kms-sql-analysis
branch
from
November 2, 2026 11:01
4e4c9f9 to
2cb1470
Compare
JuanLeee
force-pushed
the
feat/encryption-service
branch
from
November 5, 2026 00:02
9295923 to
89f9130
Compare
JuanLeee
force-pushed
the
feat/kms-sql-analysis
branch
from
November 5, 2026 00:02
2cb1470 to
448d68c
Compare
karenc-bq
approved these changes
Nov 5, 2026
Base automatically changed from
feat/encryption-service
to
feat/kms-stack-1-call-context
November 12, 2026 09:38
JuanLeee
force-pushed
the
feat/kms-stack-1-call-context
branch
from
November 12, 2026 09:56
765339a to
a5e6595
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The KMS Encryption plugin has to know which bind parameter fills or is compared with which
table.column. psycopg and MySQL Connector/Python both usepyformatplaceholders, but they find them differently, and a placeholder attributed to the wrong column would encrypt the wrong value or let a plaintext one through. This adds that analysis as pure functions: a tokenizer that finds placeholders exactly as each driver does, and an analyzer that parses the statement withsqlglot. It is based on #1300 and extends itsSqlDialectandMetadataManager. The plugin that uses it comes in #1331, so this PR has no plugin or interception code and changes nothing in the wrapper core.sql_tokenizer.pymodels each driver's placeholder rules. The drivers disagree on%%, on when they substitute at all and on which%sequences they reject, and neither skips quoted literals or comments, so a%sinside'...'is still a bound parameter. The tokenizer counts it, since skipping it would shift every later value onto the wrong column, and marks it hidden, which makes the analysis incomplete. For such a statement the analysis still lists the tables, if sqlglot can parse it, so the plugin can refuse the statement when one of them has encrypted columns. It also reads/*@encrypt:table.column*/annotations, detects MySQL executable comments (/*! */,/*M! */), records charset introducers (_binary %s, which Django renders for binary values), and writes a normalized copy of the SQL with each placeholder renamed:_kms_p<ordinal>. It has no third-party dependencies.sql_analyzer.pyparses the normalized copy and returns a frozenStatementAnalysis: the statement kind and whether it writes, the tables it names and writes,assignments(placeholders written into a column),comparisons(placeholders compared with a column),annotations,unbound_writes(columns written with a literal, an expression,DEFAULT, another column or aCOPYstream),result_columns(the table column each select-list orRETURNINGitem comes from), andgap, which says why the analysis is incomplete.parameter_columns()gives the column for each ordinal, with annotations taking precedence. It covers INSERT (multi-row,INSERT ... SET, MySQLREPLACE),ON CONFLICTandON DUPLICATE KEY UPDATE(row aliases included), UPDATE (PGUPDATE ... FROM, MySQL multi-table), DELETE, MERGE, data-modifying CTEs, COPY, PGPREPARE ... AS, several statements in one string, and SQLAlchemy's multi-row insert form (INSERT ... SELECT ... FROM (VALUES ...) AS v(...)).Query.first()with a joined eager load reads its columns that way. A CTE sees only the CTEs written before it, as both engines resolve aWITHwithoutRECURSIVE. Set operations,*, expressions and sources whose alias lists column names leave a column unknown, andRETURNINGlists are not followed.SqlDialect.fold_identifierreturns the name the server resolves an identifier to, andSqlDialect.metadata_keythe form a name is compared in againstencryption_metadata. PostgreSQL lower-cases unquoted names (ASCII only) and compares exactly. MySQL keeps names as written and compares them case-insensitively, as the uncached metadata query'sWHEREdoes under MySQL's default collations.MetadataManager's cached lookups now compare the same way, so on MySQL withlower_case_table_names=0, two tables whose names differ only in case share encryption metadata. A name the driver returns as bytes (psycopg on aSQL_ASCIIdatabase, Connector/Python withuse_unicode=False) is decoded as UTF-8, and anything else raisesMetadataError, since a name that never matches would make an encrypted column look like an ordinary one.How it fails safe
When the analysis can't establish what a writing statement writes, it reports a gap, and the plugin passes the statement through to the database trigger. A hidden placeholder is the exception: the plugin refuses the statement when it names a table with encrypted columns. When the analysis sees an encrypted column written with something other than a bind parameter, it reports an unbound write, and the plugin refuses the statement. The tests try hardest to rule out a placeholder attributed to the wrong column.
PLACEHOLDER_MISMATCH. The ordinal travels in each placeholder's name because sqlglot's tree order isn't source order, and?is a jsonb operator on PostgreSQL.UPDATE LOW_PRIORITY usersas a table namedLOW_PRIORITY, so MySQL statement modifiers are removed before parsing, and a leadingREPLACEis read asINSERT.EXPLAIN ANALYZE INSERT,EXECUTE,LOAD DATA) is classified from its keywords. sqlglot logs statement text, literals included, when it falls back to a generic command, so a thread-local filter drops its log records while this module parses, anddetailnever contains SQL text.EXCLUDED.ssn,VALUES(ssn)or a MySQL row alias) is not an unbound write./*@encrypt:ssn*/) gets a table only when the statement says which: the table already inferred for that placeholder, else the single table a written value is written to, else the only table a compared value's query reads. A written value that reaches the write through a CTE or a FROM, JOIN, LATERAL or USING source stays bare. A value compared or tested inside a written expression (aCASEcondition, the condition ofIF(), a regex or JSON operator) counts as compared, so it never takes the written table. An annotation accepts every[\w.]+reference that ends in a column, and quoted parts; one that ends in.names no column and is reported as stray.lru_cache(1024)) only for SQL of at most 4096 characters and 64%signs. ARecursionErrorresult is never cached, because it depends on the caller's stack depth.Dependency
sqlglotis optional, likecryptography. It is installed only with theencryptionextra (pip install "aws-advanced-python-wrapper[encryption]") and imported only bysql_analyzer.py, so the wrapper and the tokenizer import without it, and importing the analyzer without it raises anImportErrorthat names the extra. The pin is>=30.14.0,<31.0.0, with 30.25.0 in the lock file. It stops below the next major because the analysis walks sqlglot's expression tree, which a major release can change, so moving to a new major is a dependency bump that has to pass the analyzer's tests. The dev dependency group also hassqlglot, so the unit tests run without the extra.Testing
test_sql_tokenizer.py(135 tests) uses the drivers' own placeholder scanners as an oracle, so a driver upgrade that changes binding fails there.test_sql_analyzer.py(373) covers the shapes above and the result-column tracing, including nesting to 8 levels and one past it and which CTEs a CTE sees, with and withoutRECURSIVE.test_metadata_manager.pygains 10 cases andtest_encryption_import_guard.py4.pytest ./tests/unit -Werrorpasses 3940 tests, and flake8, isort and mypy are clean on Python 3.10 and 3.12.Through the plugin in #1331, the analysis also ran on Aurora PostgreSQL 17.9 and Aurora MySQL 3.10.3 with psycopg and both mysql-connector builds: every supported type, NULL,
executemany, every fetch shape and loop,UPDATE, SQLAlchemy Core and ORM on both engines and Django on MySQL. Every stored value, read back without the plugin, was an encrypted payload.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.