diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fa83c27..fa708562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.11.0](https://github.com/googleapis/python-spanner-sqlalchemy/compare/v1.10.0...v1.11.0) (2025-05-07) + + +### Features + +* Add isolation level support and sample ([#652](https://github.com/googleapis/python-spanner-sqlalchemy/issues/652)) ([0aba318](https://github.com/googleapis/python-spanner-sqlalchemy/commit/0aba31835bc581a0a05e29b5878ba0a665686414)) +* Add SpannerPickleType ([#655](https://github.com/googleapis/python-spanner-sqlalchemy/issues/655)) ([0837542](https://github.com/googleapis/python-spanner-sqlalchemy/commit/0837542e5606ab9ea7a8765bf54524ebf9b0dd71)), closes [#654](https://github.com/googleapis/python-spanner-sqlalchemy/issues/654) +* Support schemas in queries and dml statements ([#639](https://github.com/googleapis/python-spanner-sqlalchemy/issues/639)) ([81c154a](https://github.com/googleapis/python-spanner-sqlalchemy/commit/81c154a37b82315a8bb57319ba11272626addad3)) + + +### Bug Fixes + +* Column order in get_multi_pk_constraint ([#640](https://github.com/googleapis/python-spanner-sqlalchemy/issues/640)) ([16c87e4](https://github.com/googleapis/python-spanner-sqlalchemy/commit/16c87e4fbf1b9d5dbac0e3279cce078a2d09e4b4)) +* Include schema when creating indices ([#637](https://github.com/googleapis/python-spanner-sqlalchemy/issues/637)) ([41905e2](https://github.com/googleapis/python-spanner-sqlalchemy/commit/41905e21b5b6473d5dbf75d40db765ebf48235dc)) + ## [1.10.0](https://github.com/googleapis/python-spanner-sqlalchemy/compare/v1.9.0...v1.10.0) (2025-03-17) diff --git a/README.rst b/README.rst index 927848dc..2a195455 100644 --- a/README.rst +++ b/README.rst @@ -465,7 +465,6 @@ Other limitations ~~~~~~~~~~~~~~~~~ - WITH RECURSIVE statement is not supported. -- Named schemas are not supported. - Temporary tables are not supported. - Numeric type dimensions (scale and precision) are constant. See the `docs `__. diff --git a/google/cloud/sqlalchemy_spanner/requirements.py b/google/cloud/sqlalchemy_spanner/requirements.py index a6e6dc00..04d21dc8 100644 --- a/google/cloud/sqlalchemy_spanner/requirements.py +++ b/google/cloud/sqlalchemy_spanner/requirements.py @@ -102,7 +102,10 @@ def get_isolation_levels(self, _): Returns: dict: isolation levels description. """ - return {"default": "SERIALIZABLE", "supported": ["SERIALIZABLE", "AUTOCOMMIT"]} + return { + "default": "SERIALIZABLE", + "supported": ["SERIALIZABLE", "REPEATABLE READ", "AUTOCOMMIT"], + } @property def precision_numerics_enotation_large(self): diff --git a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index 9670327f..f2add3a1 100644 --- a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -11,6 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import base64 import pkg_resources import re @@ -24,10 +25,10 @@ ) from google.api_core.client_options import ClientOptions from google.auth.credentials import AnonymousCredentials -from google.cloud.spanner_v1 import Client +from google.cloud.spanner_v1 import Client, TransactionOptions from sqlalchemy.exc import NoSuchTableError from sqlalchemy.sql import elements -from sqlalchemy import ForeignKeyConstraint, types +from sqlalchemy import ForeignKeyConstraint, types, TypeDecorator, PickleType from sqlalchemy.engine.base import Engine from sqlalchemy.engine.default import DefaultDialect, DefaultExecutionContext from sqlalchemy.event import listens_for @@ -78,6 +79,35 @@ def reset_connection(dbapi_conn, connection_record, reset_state=None): OPERATORS[json_getitem_op] = operator_lookup["json_getitem_op"] +# PickleType that can be used with Spanner. +# Binary values are automatically encoded/decoded to/from base64. +# Usage: +# class User(Base): +# __tablename__ = 'users' +# +# user_id = Column(Integer, primary_key=True) +# username = Column(String(50), nullable=False) +# preferences = Column(PickleType(impl=SpannerPickleType)) +class SpannerPickleType(TypeDecorator): + impl = PickleType + + def bind_processor(self, dialect): + def process(value): + if value is None: + return None + return base64.standard_b64encode(value) + + return process + + def result_processor(self, dialect, coltype): + def process(value): + if value is None: + return None + return base64.standard_b64decode(value) + + return process + + # Spanner-to-SQLAlchemy types map _type_map = { "BOOL": types.Boolean, @@ -188,6 +218,16 @@ def pre_exec(self): if request_tag: self.cursor.request_tag = request_tag + ignore_transaction_warnings = self.execution_options.get( + "ignore_transaction_warnings" + ) + if ignore_transaction_warnings is not None: + conn = self._dbapi_connection.connection + if conn is not None and hasattr(conn, "_connection_variables"): + conn._connection_variables[ + "ignore_transaction_warnings" + ] = ignore_transaction_warnings + def fire_sequence(self, seq, type_): """Builds a statement for fetching next value of the sequence.""" return self._execute_scalar( @@ -233,6 +273,10 @@ class SpannerSQLCompiler(SQLCompiler): compound_keywords = _compound_keywords + def __init__(self, *args, **kwargs): + self.tablealiases = {} + super().__init__(*args, **kwargs) + def get_from_hint_text(self, _, text): """Return a hint text. @@ -378,8 +422,11 @@ def limit_clause(self, select, **kw): return text def returning_clause(self, stmt, returning_cols, **kw): + # Set the spanner_is_returning flag which is passed to visit_column. columns = [ - self._label_select_column(None, c, True, False, {}) + self._label_select_column( + None, c, True, False, {"spanner_is_returning": True} + ) for c in expression._select_iterables(returning_cols) ] @@ -391,6 +438,98 @@ def visit_sequence(self, seq, **kw): seq ) + def visit_table(self, table, spanner_aliased=False, iscrud=False, **kwargs): + """Produces the table name. + + Schema names are not allowed in Spanner SELECT statements. We + need to avoid generating SQL like + + SELECT schema.tbl.id + FROM schema.tbl + + To do so, we alias the table in order to produce SQL like: + + SELECT tbl_1.id, tbl_1.col + FROM schema.tbl AS tbl_1 + + And do similar for UPDATE and DELETE statements. + + This closely mirrors the mssql dialect which also avoids + schema-qualified columns in SELECTs, although the behaviour is + currently behind a deprecated 'legacy_schema_aliasing' flag. + """ + if spanner_aliased is table or self.isinsert: + return super().visit_table(table, **kwargs) + + # Add an alias for schema-qualified tables. + # Tables in the default schema are not aliased and follow the + # standard SQLAlchemy code path. + alias = self._schema_aliased_table(table) + if alias is not None: + return self.process(alias, spanner_aliased=table, **kwargs) + else: + return super().visit_table(table, **kwargs) + + def visit_alias(self, alias, **kw): + """Produces alias statements.""" + # translate for schema-qualified table aliases + kw["spanner_aliased"] = alias.element + return super().visit_alias(alias, **kw) + + def visit_column( + self, column, add_to_result_map=None, spanner_is_returning=False, **kw + ): + """Produces column expressions. + + In tandem with visit_table, replaces schema-qualified column + names with column names qualified against an alias. + """ + if column.table is not None and not self.isinsert or self.is_subquery(): + # translate for schema-qualified table aliases + t = self._schema_aliased_table(column.table) + if t is not None: + converted = elements._corresponding_column_or_error(t, column) + if add_to_result_map is not None: + add_to_result_map( + column.name, + column.name, + (column, column.name, column.key), + column.type, + ) + + return super().visit_column(converted, **kw) + if spanner_is_returning: + # Set include_table=False because although table names are + # allowed in RETURNING clauses, schema names are not. We + # can't use the same aliasing trick above that we use with + # other statements, because INSERT statements don't result + # in visit_table calls and INSERT table names can't be + # aliased. Statements like: + # + # INSERT INTO table (id, name) + # SELECT id, name FROM another_table + # THEN RETURN another_table.id + # + # aren't legal, so the columns remain unambiguous when not + # qualified by table name. + kw["include_table"] = False + + return super().visit_column(column, add_to_result_map=add_to_result_map, **kw) + + def _schema_aliased_table(self, table): + """Creates an alias for the table if it is schema-qualified. + + If the table is schema-qualified, returns an alias for the + table and caches the alias for future references to the + table. If the table is not schema-qualified, returns None. + """ + if getattr(table, "schema", None) is not None: + if table not in self.tablealiases: + self.tablealiases[table] = table.alias() + return self.tablealiases[table] + else: + return None + class SpannerDDLCompiler(DDLCompiler): """Spanner DDL statements compiler.""" @@ -529,7 +668,7 @@ def post_create_table(self, table): return post_cmds def visit_create_index( - self, create, include_schema=False, include_table_schema=True, **kw + self, create, include_schema=True, include_table_schema=True, **kw ): text = super().visit_create_index( create, include_schema, include_table_schema, **kw @@ -648,6 +787,7 @@ class SpannerDialect(DefaultDialect): encoding = "utf-8" max_identifier_length = 256 _legacy_binary_type_literal_encoding = "utf-8" + _default_isolation_level = "SERIALIZABLE" execute_sequence_format = list @@ -699,12 +839,11 @@ def default_isolation_level(self): Returns: str: default isolation level. """ - return "SERIALIZABLE" + return self._default_isolation_level @default_isolation_level.setter def default_isolation_level(self, value): - """Default isolation level should not be changed.""" - pass + self._default_isolation_level = value def _check_unicode_returns(self, connection, additional_tests=None): """Ensure requests are returning Unicode responses.""" @@ -1205,9 +1344,9 @@ def get_multi_pk_constraint( table_type_query = self._get_table_type_query(kind, True) sql = """ - SELECT tc.table_schema, tc.table_name, ccu.COLUMN_NAME + SELECT tc.table_schema, tc.table_name, kcu.column_name FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc - JOIN INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE AS ccu + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS kcu USING (TABLE_CATALOG, TABLE_SCHEMA, CONSTRAINT_NAME) JOIN information_schema.tables AS t ON tc.TABLE_CATALOG = t.TABLE_CATALOG @@ -1215,6 +1354,8 @@ def get_multi_pk_constraint( AND tc.TABLE_NAME = t.TABLE_NAME WHERE {table_filter_query} {table_type_query} {schema_filter_query} tc.CONSTRAINT_TYPE = "PRIMARY KEY" + ORDER BY tc.table_catalog ASC, tc.table_schema ASC, + tc.table_name ASC, kcu.ordinal_position ASC """.format( table_filter_query=table_filter_query, table_type_query=table_type_query, @@ -1551,7 +1692,7 @@ def set_isolation_level(self, conn_proxy, level): spanner_dbapi.connection.Connection, ] ): - Database connection proxy object or the connection iself. + Database connection proxy object or the connection itself. level (string): Isolation level. """ if isinstance(conn_proxy, spanner_dbapi.Connection): @@ -1559,7 +1700,13 @@ def set_isolation_level(self, conn_proxy, level): else: conn = conn_proxy.connection - conn.autocommit = level == "AUTOCOMMIT" + if level == "AUTOCOMMIT": + conn.autocommit = True + else: + if isinstance(level, str): + level = self._string_to_isolation_level(level) + conn.isolation_level = level + conn.autocommit = False def get_isolation_level(self, conn_proxy): """Get the connection isolation level. @@ -1571,7 +1718,7 @@ def get_isolation_level(self, conn_proxy): spanner_dbapi.connection.Connection, ] ): - Database connection proxy object or the connection iself. + Database connection proxy object or the connection itself. Returns: str: the connection isolation level. @@ -1581,7 +1728,31 @@ def get_isolation_level(self, conn_proxy): else: conn = conn_proxy.connection - return "AUTOCOMMIT" if conn.autocommit else "SERIALIZABLE" + if conn.autocommit: + return "AUTOCOMMIT" + + level = conn.isolation_level + if level == TransactionOptions.IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED: + level = TransactionOptions.IsolationLevel.SERIALIZABLE + if isinstance(level, TransactionOptions.IsolationLevel): + level = self._isolation_level_to_string(level) + + return level + + def _string_to_isolation_level(self, name): + try: + # SQLAlchemy guarantees that the isolation level string will: + # 1. Be all upper case. + # 2. Contain spaces instead of underscores. + # We change the spaces into underscores to get the enum value. + return TransactionOptions.IsolationLevel[name.replace(" ", "_")] + except KeyError: + raise ValueError("Invalid isolation level name '%s'" % name) + + def _isolation_level_to_string(self, level): + # SQLAlchemy expects isolation level names to contain spaces, + # and not underscores, so we remove those before returning. + return level.name.replace("_", " ") def do_rollback(self, dbapi_connection): """ diff --git a/requirements.txt b/requirements.txt index c183046c..e7fd31f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,9 +4,9 @@ # # pip-compile --generate-hashes # -alembic==1.15.1 \ - --hash=sha256:197de710da4b3e91cf66a826a5b31b5d59a127ab41bd0fc42863e2902ce2bbbe \ - --hash=sha256:e1a1c738577bca1f27e68728c910cd389b9a92152ff91d902da649c192e30c49 +alembic==1.15.2 \ + --hash=sha256:1c72391bbdeffccfe317eefba686cb9a3c078005478885413b95c3b26c57a8a7 \ + --hash=sha256:2e76bd916d547f6900ec4bb5a90aeac1485d2c92536923d0b138c02b126edc53 # via -r requirements.in build==1.2.2.post1 \ --hash=sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5 \ @@ -18,103 +18,103 @@ cachetools==5.5.2 \ --hash=sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4 \ --hash=sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a # via google-auth -certifi==2025.1.31 \ - --hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 \ - --hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe +certifi==2025.4.26 \ + --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \ + --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3 # via requests -charset-normalizer==3.4.1 \ - --hash=sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537 \ - --hash=sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa \ - --hash=sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a \ - --hash=sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294 \ - --hash=sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b \ - --hash=sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd \ - --hash=sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601 \ - --hash=sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd \ - --hash=sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4 \ - --hash=sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d \ - --hash=sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2 \ - --hash=sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313 \ - --hash=sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd \ - --hash=sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa \ - --hash=sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8 \ - --hash=sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1 \ - --hash=sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2 \ - --hash=sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496 \ - --hash=sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d \ - --hash=sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b \ - --hash=sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e \ - --hash=sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a \ - --hash=sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4 \ - --hash=sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca \ - --hash=sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78 \ - --hash=sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408 \ - --hash=sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5 \ - --hash=sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3 \ - --hash=sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f \ - --hash=sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a \ - --hash=sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765 \ - --hash=sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6 \ - --hash=sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146 \ - --hash=sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6 \ - --hash=sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9 \ - --hash=sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd \ - --hash=sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c \ - --hash=sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f \ - --hash=sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545 \ - --hash=sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176 \ - --hash=sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770 \ - --hash=sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824 \ - --hash=sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f \ - --hash=sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf \ - --hash=sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487 \ - --hash=sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d \ - --hash=sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd \ - --hash=sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b \ - --hash=sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534 \ - --hash=sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f \ - --hash=sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b \ - --hash=sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9 \ - --hash=sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd \ - --hash=sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125 \ - --hash=sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9 \ - --hash=sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de \ - --hash=sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11 \ - --hash=sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d \ - --hash=sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35 \ - --hash=sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f \ - --hash=sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda \ - --hash=sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7 \ - --hash=sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a \ - --hash=sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971 \ - --hash=sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8 \ - --hash=sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41 \ - --hash=sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d \ - --hash=sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f \ - --hash=sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757 \ - --hash=sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a \ - --hash=sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886 \ - --hash=sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77 \ - --hash=sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76 \ - --hash=sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247 \ - --hash=sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85 \ - --hash=sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb \ - --hash=sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7 \ - --hash=sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e \ - --hash=sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6 \ - --hash=sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037 \ - --hash=sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1 \ - --hash=sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e \ - --hash=sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807 \ - --hash=sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407 \ - --hash=sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c \ - --hash=sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12 \ - --hash=sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3 \ - --hash=sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089 \ - --hash=sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd \ - --hash=sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e \ - --hash=sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00 \ - --hash=sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616 +charset-normalizer==3.4.2 \ + --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ + --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ + --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ + --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ + --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ + --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ + --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \ + --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ + --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \ + --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ + --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \ + --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \ + --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ + --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ + --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ + --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \ + --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \ + --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ + --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ + --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ + --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ + --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ + --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ + --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ + --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ + --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \ + --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \ + --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ + --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \ + --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ + --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ + --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \ + --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \ + --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ + --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ + --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \ + --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \ + --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \ + --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ + --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ + --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ + --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ + --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \ + --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ + --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \ + --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ + --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \ + --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ + --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ + --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ + --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ + --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ + --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ + --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ + --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ + --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ + --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ + --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \ + --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ + --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ + --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ + --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ + --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ + --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ + --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \ + --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ + --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ + --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ + --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ + --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ + --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ + --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ + --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \ + --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ + --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ + --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \ + --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ + --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ + --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ + --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ + --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \ + --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ + --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ + --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ + --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ + --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ + --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ + --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \ + --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ + --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ + --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ + --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f # via requests click==8.1.8 \ --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ @@ -134,9 +134,9 @@ google-api-core[grpc]==2.24.2 \ # via # google-cloud-core # google-cloud-spanner -google-auth==2.38.0 \ - --hash=sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4 \ - --hash=sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a +google-auth==2.40.1 \ + --hash=sha256:58f0e8416a9814c1d86c9b7f6acf6816b51aba167b2c76821965271bac275540 \ + --hash=sha256:ed4cae4f5c46b41bae1d19c036e06f6c371926e97b19e816fc854eff811974ee # via # google-api-core # google-cloud-core @@ -144,20 +144,20 @@ google-cloud-core==2.4.3 \ --hash=sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53 \ --hash=sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e # via google-cloud-spanner -google-cloud-spanner==3.53.0 \ - --hash=sha256:0c7be3134b74928cf928d1f73b58c722fc2014346de1240a0cc8ffdd3222f606 \ - --hash=sha256:be863394521b44df3c5a118c00c4b7c978d4437adb49e359e39b3d76362a7e60 +google-cloud-spanner==3.54.0 \ + --hash=sha256:81987b3fc7d9930e03f51bcb6c6567db62838b00bdfa82aeb708584f0536fc0c \ + --hash=sha256:eef44f1207d6fae52819099cadfb225a19596e6551216831de6cbc245725efe4 # via -r requirements.in -googleapis-common-protos[grpc]==1.69.1 \ - --hash=sha256:4077f27a6900d5946ee5a369fab9c8ded4c0ef1c6e880458ea2f70c14f7b70d5 \ - --hash=sha256:e20d2d8dda87da6fe7340afbbdf4f0bcb4c8fae7e6cadf55926c31f946b0b9b1 +googleapis-common-protos[grpc]==1.70.0 \ + --hash=sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257 \ + --hash=sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8 # via # google-api-core # grpc-google-iam-v1 # grpcio-status -grpc-google-iam-v1==0.14.1 \ - --hash=sha256:14149f37af0e5779fa8a22a8ae588663269e8a479d9c2e69a5056e589bf8a891 \ - --hash=sha256:b4eca35b2231dd76066ebf1728f3cd30d51034db946827ef63ef138da14eea16 +grpc-google-iam-v1==0.14.2 \ + --hash=sha256:a3171468459770907926d56a440b2bb643eec1d7ba215f48f3ecece42b4d8351 \ + --hash=sha256:b3e1fc387a1a329e41672197d0ace9de22c78dd7d215048c4c78712073f7bd20 # via google-cloud-spanner grpc-interceptor==0.15.4 \ --hash=sha256:0035f33228693ed3767ee49d937bac424318db173fef4d2d0170b3215f254d9d \ @@ -229,13 +229,13 @@ idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 # via requests -importlib-metadata==8.6.1 \ - --hash=sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e \ - --hash=sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580 +importlib-metadata==8.7.0 \ + --hash=sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000 \ + --hash=sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd # via opentelemetry-api -Mako==1.3.9 \ - --hash=sha256:95920acccb578427a9aa38e37a186b1e43156c87260d7ba18ca63aa4c7cbd3a1 \ - --hash=sha256:b5d65ff3462870feec922dbccf38f6efb44e5714d7b593a656be86663d8600ac +Mako==1.3.10 \ + --hash=sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28 \ + --hash=sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59 # via alembic markupsafe==3.0.2 \ --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ @@ -300,9 +300,9 @@ markupsafe==3.0.2 \ --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 # via mako -opentelemetry-api==1.31.0 \ - --hash=sha256:145b72c6c16977c005c568ec32f4946054ab793d8474a17fd884b0397582c5f2 \ - --hash=sha256:d8da59e83e8e3993b4726e4c1023cd46f57c4d5a73142e239247e7d814309de1 +opentelemetry-api==1.32.1 \ + --hash=sha256:a5be71591694a4d9195caf6776b055aa702e964d961051a0715d05f8632c32fb \ + --hash=sha256:bbd19f14ab9f15f0e85e43e6a958aa4cb1f36870ee62b7fd205783a112012724 # via # -r requirements.in # opentelemetry-instrumentation @@ -312,17 +312,17 @@ opentelemetry-instrumentation==0.48b0 \ --hash=sha256:94929685d906380743a71c3970f76b5f07476eea1834abd5dd9d17abfe23cc35 \ --hash=sha256:a69750dc4ba6a5c3eb67986a337185a25b739966d80479befe37b546fc870b44 # via -r requirements.in -opentelemetry-sdk==1.31.0 \ - --hash=sha256:452d7d5b3c1db2e5e4cb64abede0ddd20690cb244a559c73a59652fdf6726070 \ - --hash=sha256:97c9a03865e69723725fb64fe04343a488c3e61e684eb804bd7d6da2215dfc60 +opentelemetry-sdk==1.32.1 \ + --hash=sha256:8ef373d490961848f525255a42b193430a0637e064dd132fd2a014d94792a092 \ + --hash=sha256:bba37b70a08038613247bc42beee5a81b0ddca422c7d7f1b097b32bf1c7e2f17 # via -r requirements.in opentelemetry-semantic-conventions==0.48b0 \ --hash=sha256:12d74983783b6878162208be57c9effcb89dc88691c64992d70bb89dc00daa1a \ --hash=sha256:a0de9f45c413a8669788a38569c7e0a11ce6ce97861a628cca785deecdc32a1f # via opentelemetry-sdk -packaging==24.2 \ - --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \ - --hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f +packaging==25.0 \ + --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ + --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f # via # -r requirements.in # build @@ -340,18 +340,18 @@ proto-plus==1.26.1 \ # via # google-api-core # google-cloud-spanner -protobuf==5.29.3 \ - --hash=sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f \ - --hash=sha256:0eb32bfa5219fc8d4111803e9a690658aa2e6366384fd0851064b963b6d1f2a7 \ - --hash=sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888 \ - --hash=sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620 \ - --hash=sha256:6ce8cc3389a20693bfde6c6562e03474c40851b44975c9b2bf6df7d8c4f864da \ - --hash=sha256:84a57163a0ccef3f96e4b6a20516cedcf5bb3a95a657131c5c3ac62200d23252 \ - --hash=sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a \ - --hash=sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e \ - --hash=sha256:b89c115d877892a512f79a8114564fb435943b59067615894c3b13cd3e1fa107 \ - --hash=sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f \ - --hash=sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84 +protobuf==5.29.4 \ + --hash=sha256:13eb236f8eb9ec34e63fc8b1d6efd2777d062fa6aaa68268fb67cf77f6839ad7 \ + --hash=sha256:1832f0515b62d12d8e6ffc078d7e9eb06969aa6dc13c13e1036e39d73bebc2de \ + --hash=sha256:307ecba1d852ec237e9ba668e087326a67564ef83e45a0189a772ede9e854dd0 \ + --hash=sha256:3fde11b505e1597f71b875ef2fc52062b6a9740e5f7c8997ce878b6009145862 \ + --hash=sha256:476cb7b14914c780605a8cf62e38c2a85f8caff2e28a6a0bad827ec7d6c85d68 \ + --hash=sha256:4f1dfcd7997b31ef8f53ec82781ff434a28bf71d9102ddde14d076adcfc78c99 \ + --hash=sha256:678974e1e3a9b975b8bc2447fca458db5f93a2fb6b0c8db46b6675b5b5346812 \ + --hash=sha256:aec4962f9ea93c431d5714ed1be1c93f13e1a8618e70035ba2b0564d9e633f2e \ + --hash=sha256:bcefcdf3976233f8a502d265eb65ea740c989bacc6c30a58290ed0e519eb4b8d \ + --hash=sha256:d7d3f7d1d5a66ed4942d4fefb12ac4b14a29028b209d4bfb25c68ae172059922 \ + --hash=sha256:fd32223020cb25a2cc100366f1dedc904e2d71d9322403224cdde5fdced0dabe # via # google-api-core # google-cloud-spanner @@ -365,13 +365,13 @@ pyasn1==0.6.1 \ # via # pyasn1-modules # rsa -pyasn1-modules==0.4.1 \ - --hash=sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd \ - --hash=sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c +pyasn1-modules==0.4.2 \ + --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \ + --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6 # via google-auth -pyparsing==3.2.1 \ - --hash=sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1 \ - --hash=sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a +pyparsing==3.2.3 \ + --hash=sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf \ + --hash=sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be # via -r requirements.in pyproject-hooks==1.2.0 \ --hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \ @@ -383,53 +383,68 @@ requests==2.32.3 \ --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 # via google-api-core -rsa==4.9 \ - --hash=sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7 \ - --hash=sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21 +rsa==4.9.1 \ + --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 \ + --hash=sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75 # via google-auth -SQLAlchemy==2.0.39 \ - --hash=sha256:018ee97c558b499b58935c5a152aeabf6d36b3d55d91656abeb6d93d663c0c4c \ - --hash=sha256:01da15490c9df352fbc29859d3c7ba9cd1377791faeeb47c100832004c99472c \ - --hash=sha256:04545042969833cb92e13b0a3019549d284fd2423f318b6ba10e7aa687690a3c \ - --hash=sha256:08cf721bbd4391a0e765fe0fe8816e81d9f43cece54fdb5ac465c56efafecb3d \ - --hash=sha256:23c5aa33c01bd898f879db158537d7e7568b503b15aad60ea0c8da8109adf3e7 \ - --hash=sha256:2d7332868ce891eda48896131991f7f2be572d65b41a4050957242f8e935d5d7 \ - --hash=sha256:2ed107331d188a286611cea9022de0afc437dd2d3c168e368169f27aa0f61338 \ - --hash=sha256:34d5c49f18778a3665d707e6286545a30339ad545950773d43977e504815fa70 \ - --hash=sha256:35e72518615aa5384ef4fae828e3af1b43102458b74a8c481f69af8abf7e802a \ - --hash=sha256:3eb14ba1a9d07c88669b7faf8f589be67871d6409305e73e036321d89f1d904e \ - --hash=sha256:412c6c126369ddae171c13987b38df5122cb92015cba6f9ee1193b867f3f1530 \ - --hash=sha256:4600c7a659d381146e1160235918826c50c80994e07c5b26946a3e7ec6c99249 \ - --hash=sha256:463ecfb907b256e94bfe7bcb31a6d8c7bc96eca7cbe39803e448a58bb9fcad02 \ - --hash=sha256:4a06e6c8e31c98ddc770734c63903e39f1947c9e3e5e4bef515c5491b7737dde \ - --hash=sha256:4b2de1523d46e7016afc7e42db239bd41f2163316935de7c84d0e19af7e69538 \ - --hash=sha256:4dabd775fd66cf17f31f8625fc0e4cfc5765f7982f94dc09b9e5868182cb71c0 \ - --hash=sha256:52607d0ebea43cf214e2ee84a6a76bc774176f97c5a774ce33277514875a718e \ - --hash=sha256:5493a8120d6fc185f60e7254fc056a6742f1db68c0f849cfc9ab46163c21df47 \ - --hash=sha256:5d2d1fe548def3267b4c70a8568f108d1fed7cbbeccb9cc166e05af2abc25c22 \ - --hash=sha256:5dfbc543578058c340360f851ddcecd7a1e26b0d9b5b69259b526da9edfa8875 \ - --hash=sha256:67de057fbcb04a066171bd9ee6bcb58738d89378ee3cabff0bffbf343ae1c787 \ - --hash=sha256:6827f8c1b2f13f1420545bd6d5b3f9e0b85fe750388425be53d23c760dcf176b \ - --hash=sha256:6b35e07f1d57b79b86a7de8ecdcefb78485dab9851b9638c2c793c50203b2ae8 \ - --hash=sha256:7399d45b62d755e9ebba94eb89437f80512c08edde8c63716552a3aade61eb42 \ - --hash=sha256:78f1b79132a69fe8bd6b5d91ef433c8eb40688ba782b26f8c9f3d2d9ca23626f \ - --hash=sha256:79f4f502125a41b1b3b34449e747a6abfd52a709d539ea7769101696bdca6716 \ - --hash=sha256:7a8517b6d4005facdbd7eb4e8cf54797dbca100a7df459fdaff4c5123265c1cd \ - --hash=sha256:7bd5c5ee1448b6408734eaa29c0d820d061ae18cb17232ce37848376dcfa3e92 \ - --hash=sha256:7f5243357e6da9a90c56282f64b50d29cba2ee1f745381174caacc50d501b109 \ - --hash=sha256:871f55e478b5a648c08dd24af44345406d0e636ffe021d64c9b57a4a11518304 \ - --hash=sha256:87a1ce1f5e5dc4b6f4e0aac34e7bb535cb23bd4f5d9c799ed1633b65c2bcad8c \ - --hash=sha256:8a10ca7f8a1ea0fd5630f02feb055b0f5cdfcd07bb3715fc1b6f8cb72bf114e4 \ - --hash=sha256:995c2bacdddcb640c2ca558e6760383dcdd68830160af92b5c6e6928ffd259b4 \ - --hash=sha256:9f03143f8f851dd8de6b0c10784363712058f38209e926723c80654c1b40327a \ - --hash=sha256:a1c6b0a5e3e326a466d809b651c63f278b1256146a377a528b6938a279da334f \ - --hash=sha256:a28f9c238f1e143ff42ab3ba27990dfb964e5d413c0eb001b88794c5c4a528a9 \ - --hash=sha256:bf555f3e25ac3a70c67807b2949bfe15f377a40df84b71ab2c58d8593a1e036e \ - --hash=sha256:c457a38351fb6234781d054260c60e531047e4d07beca1889b558ff73dc2014b \ - --hash=sha256:c4c433f78c2908ae352848f56589c02b982d0e741b7905228fad628999799de4 \ - --hash=sha256:d9f119e7736967c0ea03aff91ac7d04555ee038caf89bb855d93bbd04ae85b41 \ - --hash=sha256:f2bcb085faffcacf9319b1b1445a7e1cfdc6fb46c03f2dce7bc2d9a4b3c1cdc5 \ - --hash=sha256:fe193d3ae297c423e0e567e240b4324d6b6c280a048e64c77a3ea6886cc2aa87 +SQLAlchemy==2.0.40 \ + --hash=sha256:00a494ea6f42a44c326477b5bee4e0fc75f6a80c01570a32b57e89cf0fbef85a \ + --hash=sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d \ + --hash=sha256:110179728e442dae85dd39591beb74072ae4ad55a44eda2acc6ec98ead80d5f2 \ + --hash=sha256:15d08d5ef1b779af6a0909b97be6c1fd4298057504eb6461be88bd1696cb438e \ + --hash=sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26 \ + --hash=sha256:1abb387710283fc5983d8a1209d9696a4eae9db8d7ac94b402981fe2fe2e39ad \ + --hash=sha256:1ffdf9c91428e59744f8e6f98190516f8e1d05eec90e936eb08b257332c5e870 \ + --hash=sha256:2be94d75ee06548d2fc591a3513422b873490efb124048f50556369a834853b0 \ + --hash=sha256:2cbafc8d39ff1abdfdda96435f38fab141892dc759a2165947d1a8fffa7ef596 \ + --hash=sha256:2ee5f9999a5b0e9689bed96e60ee53c3384f1a05c2dd8068cc2e8361b0df5b7a \ + --hash=sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a \ + --hash=sha256:35904d63412db21088739510216e9349e335f142ce4a04b69e2528020ee19ed4 \ + --hash=sha256:37a5c21ab099a83d669ebb251fddf8f5cee4d75ea40a5a1653d9c43d60e20867 \ + --hash=sha256:37f7a0f506cf78c80450ed1e816978643d3969f99c4ac6b01104a6fe95c5490a \ + --hash=sha256:46628ebcec4f23a1584fb52f2abe12ddb00f3bb3b7b337618b80fc1b51177aff \ + --hash=sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705 \ + --hash=sha256:4aeb939bcac234b88e2d25d5381655e8353fe06b4e50b1c55ecffe56951d18c2 \ + --hash=sha256:50f5885bbed261fc97e2e66c5156244f9704083a674b8d17f24c72217d29baf5 \ + --hash=sha256:519624685a51525ddaa7d8ba8265a1540442a2ec71476f0e75241eb8263d6f51 \ + --hash=sha256:5434223b795be5c5ef8244e5ac98056e290d3a99bdcc539b916e282b160dda00 \ + --hash=sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364 \ + --hash=sha256:5654d1ac34e922b6c5711631f2da497d3a7bffd6f9f87ac23b35feea56098011 \ + --hash=sha256:574aea2c54d8f1dd1699449f332c7d9b71c339e04ae50163a3eb5ce4c4325ee4 \ + --hash=sha256:5cfa124eda500ba4b0d3afc3e91ea27ed4754e727c7f025f293a22f512bcd4c9 \ + --hash=sha256:5ea9181284754d37db15156eb7be09c86e16e50fbe77610e9e7bee09291771a1 \ + --hash=sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad \ + --hash=sha256:650490653b110905c10adac69408380688cefc1f536a137d0d69aca1069dc1d1 \ + --hash=sha256:6959738971b4745eea16f818a2cd086fb35081383b078272c35ece2b07012716 \ + --hash=sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0 \ + --hash=sha256:7e0505719939e52a7b0c65d20e84a6044eb3712bb6f239c6b1db77ba8e173a37 \ + --hash=sha256:8b6b28d303b9d57c17a5164eb1fd2d5119bb6ff4413d5894e74873280483eeb5 \ + --hash=sha256:8bb131ffd2165fae48162c7bbd0d97c84ab961deea9b8bab16366543deeab625 \ + --hash=sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01 \ + --hash=sha256:9408fd453d5f8990405cc9def9af46bfbe3183e6110401b407c2d073c3388f47 \ + --hash=sha256:957f8d85d5e834397ef78a6109550aeb0d27a53b5032f7a57f2451e1adc37e98 \ + --hash=sha256:9c7a80ed86d6aaacb8160a1caef6680d4ddd03c944d985aecee940d168c411d1 \ + --hash=sha256:9d3b31d0a1c44b74d3ae27a3de422dfccd2b8f0b75e51ecb2faa2bf65ab1ba0d \ + --hash=sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500 \ + --hash=sha256:a8aae085ea549a1eddbc9298b113cffb75e514eadbb542133dd2b99b5fb3b6af \ + --hash=sha256:ae9597cab738e7cc823f04a704fb754a9249f0b6695a6aeb63b74055cd417a96 \ + --hash=sha256:afe63b208153f3a7a2d1a5b9df452b0673082588933e54e7c8aac457cf35e758 \ + --hash=sha256:b5a5bbe29c10c5bfd63893747a1bf6f8049df607638c786252cb9243b86b6706 \ + --hash=sha256:baf7cee56bd552385c1ee39af360772fbfc2f43be005c78d1140204ad6148438 \ + --hash=sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db \ + --hash=sha256:bece9527f5a98466d67fb5d34dc560c4da964240d8b09024bb21c1246545e04e \ + --hash=sha256:c0cae71e20e3c02c52f6b9e9722bca70e4a90a466d59477822739dc31ac18b4b \ + --hash=sha256:c268b5100cfeaa222c40f55e169d484efa1384b44bf9ca415eae6d556f02cb08 \ + --hash=sha256:c7b927155112ac858357ccf9d255dd8c044fd9ad2dc6ce4c4149527c901fa4c3 \ + --hash=sha256:c884de19528e0fcd9dc34ee94c810581dd6e74aef75437ff17e696c2bfefae3e \ + --hash=sha256:cd2f75598ae70bcfca9117d9e51a3b06fe29edd972fdd7fd57cc97b4dbf3b08a \ + --hash=sha256:cf0e99cdb600eabcd1d65cdba0d3c91418fee21c4aa1d28db47d095b1064a7d8 \ + --hash=sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00 \ + --hash=sha256:e8040680eaacdce4d635f12c55c714f3d4c7f57da2bc47a01229d115bd319191 \ + --hash=sha256:f0fda83e113bb0fb27dc003685f32a5dcb99c9c4f41f4fa0838ac35265c23b5c \ + --hash=sha256:f1ea21bef99c703f44444ad29c2c1b6bd55d202750b6de8e06a955380f4725d7 \ + --hash=sha256:f6bacab7514de6146a1976bc56e1545bee247242fab030b89e5f70336fc0003e \ + --hash=sha256:fe147fcd85aaed53ce90645c91ed5fca0cc88a797314c70dfd9d35925bd5d106 # via # -r requirements.in # alembic @@ -471,16 +486,16 @@ tomli==2.2.1 \ --hash=sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a \ --hash=sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7 # via -r requirements.in -typing-extensions==4.12.2 \ - --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ - --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 +typing-extensions==4.13.2 \ + --hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \ + --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef # via # alembic # opentelemetry-sdk # sqlalchemy -urllib3==2.3.0 \ - --hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \ - --hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d +urllib3==2.4.0 \ + --hash=sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466 \ + --hash=sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813 # via requests wheel==0.45.1 \ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \ diff --git a/samples/isolation_level_sample.py b/samples/isolation_level_sample.py new file mode 100644 index 00000000..ceb56643 --- /dev/null +++ b/samples/isolation_level_sample.py @@ -0,0 +1,47 @@ +# Copyright 2025 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from sample_helper import run_sample +from model import Singer + + +# Shows how to set the isolation level for a read/write transaction. +# Spanner supports the following isolation levels: +# - SERIALIZABLE (default) +# - REPEATABLE READ +def isolation_level_sample(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + # You can set a default isolation level for an engine. + isolation_level="REPEATABLE READ", + echo=True, + ) + # You can override the default isolation level of the connection + # by setting it in the execution_options. + with Session(engine.execution_options(isolation_level="SERIALIZABLE")) as session: + singer_id = str(uuid.uuid4()) + singer = Singer(id=singer_id, first_name="John", last_name="Doe") + session.add(singer) + session.commit() + + +if __name__ == "__main__": + run_sample(isolation_level_sample) diff --git a/samples/model.py b/samples/model.py index 65fc4a41..2b231ca6 100644 --- a/samples/model.py +++ b/samples/model.py @@ -32,8 +32,10 @@ Sequence, TextClause, Index, + PickleType, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from google.cloud.sqlalchemy_spanner.sqlalchemy_spanner import SpannerPickleType class Base(DeclarativeBase): @@ -64,6 +66,9 @@ class Singer(Base): ) birthdate: Mapped[Optional[datetime.date]] = mapped_column(Date, nullable=True) picture: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True) + preferences: Mapped[Optional[object]] = mapped_column( + PickleType(impl=SpannerPickleType), nullable=True + ) albums: Mapped[List["Album"]] = relationship( back_populates="singer", cascade="all, delete-orphan" ) diff --git a/samples/noxfile.py b/samples/noxfile.py index 67c3fae5..82019f5b 100644 --- a/samples/noxfile.py +++ b/samples/noxfile.py @@ -62,6 +62,11 @@ def transaction(session): _sample(session) +@nox.session() +def isolation_level(session): + _sample(session) + + @nox.session() def stale_read(session): _sample(session) diff --git a/samples/pickle_type_sample.py b/samples/pickle_type_sample.py new file mode 100644 index 00000000..58159996 --- /dev/null +++ b/samples/pickle_type_sample.py @@ -0,0 +1,55 @@ +# Copyright 2025 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from sample_helper import run_sample +from model import Singer + +# Shows how to use PickleType with Spanner. +def pickle_type(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + echo=True, + ) + with Session(engine) as session: + singer = Singer( + id=str(uuid.uuid4()), + first_name="John", + last_name="Smith", + # Preferences are stored as an opaque BYTES column + # in the database. + preferences={ + "wakeup_call": "yes", + "vegetarian": "no", + }, + ) + session.add(singer) + session.commit() + + # Use AUTOCOMMIT for sessions that only read. This is more + # efficient than using a read/write transaction to only read. + session.connection(execution_options={"isolation_level": "AUTOCOMMIT"}) + print( + f"Inserted singer {singer.full_name} has these preferences: {singer.preferences}" + ) + + +if __name__ == "__main__": + run_sample(pickle_type) diff --git a/test/mockserver_tests/isolation_level_model.py b/test/mockserver_tests/isolation_level_model.py new file mode 100644 index 00000000..9965dbf0 --- /dev/null +++ b/test/mockserver_tests/isolation_level_model.py @@ -0,0 +1,28 @@ +# Copyright 2025 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from sqlalchemy import String, BigInteger +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column + + +class Base(DeclarativeBase): + pass + + +class Singer(Base): + __tablename__ = "singers" + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + name: Mapped[str] = mapped_column(String) diff --git a/test/mockserver_tests/pickle_type_model.py b/test/mockserver_tests/pickle_type_model.py new file mode 100644 index 00000000..b3bb47c4 --- /dev/null +++ b/test/mockserver_tests/pickle_type_model.py @@ -0,0 +1,31 @@ +# Copyright 2025 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from sqlalchemy import Column, Integer, String, PickleType +from sqlalchemy.orm import DeclarativeBase + +from google.cloud.sqlalchemy_spanner.sqlalchemy_spanner import SpannerPickleType + + +class Base(DeclarativeBase): + pass + + +class UserPreferences(Base): + __tablename__ = "user_preferences" + + user_id = Column(Integer, primary_key=True) + username = Column(String(50), nullable=False) + preferences = Column(PickleType(impl=SpannerPickleType), nullable=True) + created_at = Column(String(30), nullable=False) diff --git a/test/mockserver_tests/test_auto_increment.py b/test/mockserver_tests/test_auto_increment.py index 6bc5e2c0..7fa245e8 100644 --- a/test/mockserver_tests/test_auto_increment.py +++ b/test/mockserver_tests/test_auto_increment.py @@ -125,9 +125,7 @@ def test_create_table_with_specific_sequence_kind(self): def test_insert_row(self): from test.mockserver_tests.auto_increment_model import Singer - self.add_insert_result( - "INSERT INTO singers (name) VALUES (@a0) THEN RETURN singers.id" - ) + self.add_insert_result("INSERT INTO singers (name) VALUES (@a0) THEN RETURN id") engine = create_engine( "spanner:///projects/p/instances/i/databases/d", connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, diff --git a/test/mockserver_tests/test_basics.py b/test/mockserver_tests/test_basics.py index 36aee22c..cffbda0d 100644 --- a/test/mockserver_tests/test_basics.py +++ b/test/mockserver_tests/test_basics.py @@ -21,6 +21,7 @@ MetaData, Table, Column, + Index, Integer, String, func, @@ -134,6 +135,47 @@ def test_create_table(self): requests[0].statements[0], ) + def test_create_table_in_schema(self): + add_result( + """SELECT true +FROM INFORMATION_SCHEMA.TABLES +WHERE TABLE_SCHEMA="schema" AND TABLE_NAME="users" +LIMIT 1 +""", + ResultSet(), + ) + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + ) + metadata = MetaData() + Table( + "users", + metadata, + Column("user_id", Integer, primary_key=True), + Column("user_name", String(16), nullable=False), + Index("ix_users_user_id", "user_id"), + schema="schema", + ) + metadata.create_all(engine) + requests = self.database_admin_service.requests + eq_(1, len(requests)) + is_instance_of(requests[0], UpdateDatabaseDdlRequest) + eq_(2, len(requests[0].statements)) + + eq_( + "CREATE TABLE schema.users (\n" + "\tuser_id INT64 NOT NULL " + "GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), \n" + "\tuser_name STRING(16) NOT NULL\n" + ") PRIMARY KEY (user_id)", + requests[0].statements[0], + ) + eq_( + "CREATE INDEX schema.ix_users_user_id ON schema.users (user_id)", + requests[0].statements[1], + ) + def test_create_multiple_tables(self): for i in range(2): add_result( @@ -176,7 +218,11 @@ def test_partitioned_dml(self): add_update_count(sql, 100, AutocommitDmlMode.PARTITIONED_NON_ATOMIC) engine = create_engine( "spanner:///projects/p/instances/i/databases/d", - connect_args={"client": self.client, "pool": PingingPool(size=10)}, + connect_args={ + "client": self.client, + "pool": PingingPool(size=10), + "ignore_transaction_warnings": True, + }, ) # TODO: Support autocommit_dml_mode as a connection variable in execution # options. @@ -220,3 +266,53 @@ class Singer(Base): singer.name = "New Name" session.add(singer) session.commit() + + def test_select_table_in_named_schema(self): + class Base(DeclarativeBase): + pass + + class Singer(Base): + __tablename__ = "singers" + __table_args__ = {"schema": "my_schema"} + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + name: Mapped[str] = mapped_column(String) + + query = ( + "SELECT" + " singers_1.id AS my_schema_singers_id," + " singers_1.name AS my_schema_singers_name\n" + "FROM my_schema.singers AS singers_1\n" + "WHERE singers_1.id = @a0\n" + " LIMIT @a1" + ) + add_singer_query_result(query) + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + ) + + insert = "INSERT INTO my_schema.singers (name) VALUES (@a0) THEN RETURN id" + add_single_result(insert, "id", TypeCode.INT64, [("1",)]) + with Session(engine) as session: + singer = Singer(name="New Name") + session.add(singer) + session.commit() + + update = ( + "UPDATE my_schema.singers AS singers_1 " + "SET name=@a0 " + "WHERE singers_1.id = @a1" + ) + add_update_count(update, 1) + with Session(engine) as session: + singer = session.query(Singer).filter(Singer.id == 1).first() + singer.name = "New Name" + session.add(singer) + session.commit() + + delete = "DELETE FROM my_schema.singers AS singers_1 WHERE singers_1.id = @a0" + add_update_count(delete, 1) + with Session(engine) as session: + singer = session.query(Singer).filter(Singer.id == 1).first() + session.delete(singer) + session.commit() diff --git a/test/mockserver_tests/test_bit_reversed_sequence.py b/test/mockserver_tests/test_bit_reversed_sequence.py index a18bc08e..9e7a81a8 100644 --- a/test/mockserver_tests/test_bit_reversed_sequence.py +++ b/test/mockserver_tests/test_bit_reversed_sequence.py @@ -110,7 +110,7 @@ def test_insert_row(self): add_result( "INSERT INTO singers (id, name) " "VALUES ( GET_NEXT_SEQUENCE_VALUE(SEQUENCE singer_id), @a0) " - "THEN RETURN singers.id", + "THEN RETURN id", result, ) engine = create_engine( diff --git a/test/mockserver_tests/test_isolation_level.py b/test/mockserver_tests/test_isolation_level.py new file mode 100644 index 00000000..f6545298 --- /dev/null +++ b/test/mockserver_tests/test_isolation_level.py @@ -0,0 +1,208 @@ +# Copyright 2025 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.testing import eq_, is_instance_of +from google.cloud.spanner_v1 import ( + FixedSizePool, + BatchCreateSessionsRequest, + ExecuteSqlRequest, + CommitRequest, + BeginTransactionRequest, + TransactionOptions, +) + +from test.mockserver_tests.mock_server_test_base import ( + MockServerTestBase, + add_result, +) +import google.cloud.spanner_v1.types.type as spanner_type +import google.cloud.spanner_v1.types.result_set as result_set + +ISOLATION_LEVEL_UNSPECIFIED = ( + TransactionOptions.IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED +) + + +class TestIsolationLevel(MockServerTestBase): + def test_default_isolation_level(self): + from test.mockserver_tests.isolation_level_model import Singer + + self.add_insert_result("INSERT INTO singers (name) VALUES (@a0) THEN RETURN id") + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + ) + + with Session(engine) as session: + singer = Singer(name="Test") + session.add(singer) + session.commit() + self.verify_isolation_level( + TransactionOptions.IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED + ) + + def test_engine_isolation_level(self): + from test.mockserver_tests.isolation_level_model import Singer + + self.add_insert_result("INSERT INTO singers (name) VALUES (@a0) THEN RETURN id") + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + isolation_level="REPEATABLE READ", + ) + + with Session(engine) as session: + singer = Singer(name="Test") + session.add(singer) + session.commit() + self.verify_isolation_level(TransactionOptions.IsolationLevel.REPEATABLE_READ) + + def test_execution_options_isolation_level(self): + from test.mockserver_tests.isolation_level_model import Singer + + self.add_insert_result("INSERT INTO singers (name) VALUES (@a0) THEN RETURN id") + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + ) + + with Session( + engine.execution_options(isolation_level="repeatable read") + ) as session: + singer = Singer(name="Test") + session.add(singer) + session.commit() + self.verify_isolation_level(TransactionOptions.IsolationLevel.REPEATABLE_READ) + + def test_override_engine_isolation_level(self): + from test.mockserver_tests.isolation_level_model import Singer + + self.add_insert_result("INSERT INTO singers (name) VALUES (@a0) THEN RETURN id") + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + isolation_level="REPEATABLE READ", + ) + + with Session( + engine.execution_options(isolation_level="SERIALIZABLE") + ) as session: + singer = Singer(name="Test") + session.add(singer) + session.commit() + self.verify_isolation_level(TransactionOptions.IsolationLevel.SERIALIZABLE) + + def test_auto_commit(self): + from test.mockserver_tests.isolation_level_model import Singer + + self.add_insert_result("INSERT INTO singers (name) VALUES (@a0) THEN RETURN id") + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={ + "client": self.client, + "pool": FixedSizePool(size=10), + "ignore_transaction_warnings": True, + }, + ) + + with Session( + engine.execution_options( + isolation_level="AUTOCOMMIT", ignore_transaction_warnings=True + ) + ) as session: + singer = Singer(name="Test") + session.add(singer) + session.commit() + + # Verify the requests that we got. + requests = self.spanner_service.requests + eq_(3, len(requests)) + is_instance_of(requests[0], BatchCreateSessionsRequest) + is_instance_of(requests[1], ExecuteSqlRequest) + is_instance_of(requests[2], CommitRequest) + execute_request: ExecuteSqlRequest = requests[1] + eq_( + TransactionOptions( + dict( + isolation_level=ISOLATION_LEVEL_UNSPECIFIED, + read_write=TransactionOptions.ReadWrite(), + ) + ), + execute_request.transaction.begin, + ) + + def test_invalid_isolation_level(self): + from test.mockserver_tests.isolation_level_model import Singer + + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + ) + with pytest.raises(ValueError): + with Session(engine.execution_options(isolation_level="foo")) as session: + singer = Singer(name="Test") + session.add(singer) + session.commit() + + def verify_isolation_level(self, level): + # Verify the requests that we got. + requests = self.spanner_service.requests + eq_(4, len(requests)) + is_instance_of(requests[0], BatchCreateSessionsRequest) + is_instance_of(requests[1], BeginTransactionRequest) + is_instance_of(requests[2], ExecuteSqlRequest) + is_instance_of(requests[3], CommitRequest) + begin_request: BeginTransactionRequest = requests[1] + eq_( + TransactionOptions( + dict( + isolation_level=level, + read_write=TransactionOptions.ReadWrite(), + ) + ), + begin_request.options, + ) + + def add_insert_result(self, sql): + result = result_set.ResultSet( + dict( + metadata=result_set.ResultSetMetadata( + dict( + row_type=spanner_type.StructType( + dict( + fields=[ + spanner_type.StructType.Field( + dict( + name="id", + type=spanner_type.Type( + dict(code=spanner_type.TypeCode.INT64) + ), + ) + ) + ] + ) + ) + ) + ), + stats=result_set.ResultSetStats( + dict( + row_count_exact=1, + ) + ), + ) + ) + result.rows.extend([("987654321",)]) + add_result(sql, result) diff --git a/test/mockserver_tests/test_pickle_type.py b/test/mockserver_tests/test_pickle_type.py new file mode 100644 index 00000000..b4c2e76c --- /dev/null +++ b/test/mockserver_tests/test_pickle_type.py @@ -0,0 +1,181 @@ +# Copyright 2025 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.testing import eq_, is_instance_of +from google.cloud.spanner_v1 import ( + FixedSizePool, + ResultSet, + BatchCreateSessionsRequest, + ExecuteSqlRequest, + CommitRequest, + BeginTransactionRequest, + TypeCode, +) +from test.mockserver_tests.mock_server_test_base import ( + MockServerTestBase, + add_result, + add_update_count, +) +from google.cloud.spanner_admin_database_v1 import UpdateDatabaseDdlRequest +import google.cloud.spanner_v1.types.type as spanner_type +import google.cloud.spanner_v1.types.result_set as result_set + + +class TestPickleType(MockServerTestBase): + def test_create_table(self): + from test.mockserver_tests.pickle_type_model import Base + + add_result( + """SELECT true +FROM INFORMATION_SCHEMA.TABLES +WHERE TABLE_SCHEMA="" AND TABLE_NAME="user_preferences" +LIMIT 1 +""", + ResultSet(), + ) + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + ) + Base.metadata.create_all(engine) + requests = self.database_admin_service.requests + eq_(1, len(requests)) + is_instance_of(requests[0], UpdateDatabaseDdlRequest) + eq_(1, len(requests[0].statements)) + eq_( + "CREATE TABLE user_preferences (\n" + "\tuser_id INT64 NOT NULL GENERATED BY DEFAULT" + " AS IDENTITY (BIT_REVERSED_POSITIVE), \n" + "\tusername STRING(50) NOT NULL, \n" + "\tpreferences BYTES(MAX), \n" + "\tcreated_at STRING(30) NOT NULL\n" + ") PRIMARY KEY (user_id)", + requests[0].statements[0], + ) + + def test_insert_and_query(self): + from test.mockserver_tests.pickle_type_model import UserPreferences + + add_update_count( + "INSERT INTO user_preferences (user_id, username, preferences, created_at) " + "VALUES (@a0, @a1, @a2, @a3)", + 1, + ) + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + ) + preferences = {"setting": "true"} + preferences_base64 = "gAWVFQAAAAAAAAB9lIwHc2V0dGluZ5SMBHRydWWUcy4=" + with Session(engine) as session: + new_user = UserPreferences( + user_id=1, + username="test_user", + preferences=preferences, + created_at="2025-05-04T00:00:00.000000", + ) + + session.add(new_user) + session.commit() + + # Verify the requests that we got. + requests = self.spanner_service.requests + eq_(4, len(requests)) + is_instance_of(requests[0], BatchCreateSessionsRequest) + is_instance_of(requests[1], BeginTransactionRequest) + is_instance_of(requests[2], ExecuteSqlRequest) + is_instance_of(requests[3], CommitRequest) + request: ExecuteSqlRequest = requests[2] + eq_(4, len(request.params)) + eq_("1", request.params["a0"]) + eq_("test_user", request.params["a1"]) + eq_(preferences_base64, request.params["a2"]) + eq_(TypeCode.INT64, request.param_types["a0"].code) + eq_(TypeCode.STRING, request.param_types["a1"].code) + eq_(TypeCode.BYTES, request.param_types["a2"].code) + + add_user_preferences_result( + "SELECT user_preferences.user_id AS user_preferences_user_id, " + "user_preferences.username AS user_preferences_username, " + "user_preferences.preferences AS user_preferences_preferences, " + "user_preferences.created_at AS user_preferences_created_at\n" + "FROM user_preferences\n" + "WHERE user_preferences.user_id = @a0\n" + " LIMIT @a1", + preferences_base64, + ) + user = session.query(UserPreferences).filter_by(user_id=1).first() + eq_(preferences, user.preferences) + + +def add_user_preferences_result(sql: str, preferences_base64: object): + result = result_set.ResultSet( + dict( + metadata=result_set.ResultSetMetadata( + dict( + row_type=spanner_type.StructType( + dict( + fields=[ + spanner_type.StructType.Field( + dict( + name="user_id", + type=spanner_type.Type( + dict(code=spanner_type.TypeCode.INT64) + ), + ) + ), + spanner_type.StructType.Field( + dict( + name="user_name", + type=spanner_type.Type( + dict(code=spanner_type.TypeCode.STRING) + ), + ) + ), + spanner_type.StructType.Field( + dict( + name="preferences", + type=spanner_type.Type( + dict(code=spanner_type.TypeCode.BYTES) + ), + ) + ), + spanner_type.StructType.Field( + dict( + name="created_at", + type=spanner_type.Type( + dict(code=spanner_type.TypeCode.TIMESTAMP) + ), + ) + ), + ] + ) + ) + ) + ), + ) + ) + result.rows.extend( + [ + ( + "1", + "Test", + preferences_base64, + "2025-05-05T00:00:00.000000Z", + ), + ] + ) + add_result(sql, result) diff --git a/test/system/test_basics.py b/test/system/test_basics.py index e5411988..693617b1 100644 --- a/test/system/test_basics.py +++ b/test/system/test_basics.py @@ -11,7 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import os from typing import Optional from sqlalchemy import ( text, @@ -25,6 +25,8 @@ Boolean, BIGINT, select, + update, + delete, ) from sqlalchemy.orm import Session, DeclarativeBase, Mapped, mapped_column from sqlalchemy.types import REAL @@ -58,6 +60,16 @@ def define_tables(cls, metadata): Column("name", String(20)), ) + with cls.bind.begin() as conn: + conn.execute(text("CREATE SCHEMA IF NOT EXISTS schema")) + Table( + "users", + metadata, + Column("ID", Integer, primary_key=True), + Column("name", String(20)), + schema="schema", + ) + def test_hello_world(self, connection): greeting = connection.execute(text("select 'Hello World'")) eq_("Hello World", greeting.fetchone()[0]) @@ -139,6 +151,12 @@ class User(Base): ID: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(20)) + class SchemaUser(Base): + __tablename__ = "users" + __table_args__ = {"schema": "schema"} + ID: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(20)) + engine = connection.engine with Session(engine) as session: number = Number( @@ -147,7 +165,10 @@ class User(Base): session.add(number) session.commit() - with Session(engine) as session: + level = "serializable" + if os.environ.get("SPANNER_EMULATOR_HOST", ""): + level = "REPEATABLE READ" + with Session(engine.execution_options(isolation_level=level)) as session: user = User(name="Test") session.add(user) session.commit() @@ -156,3 +177,35 @@ class User(Base): users = session.scalars(statement).all() eq_(1, len(users)) is_true(users[0].ID > 0) + + with Session(engine) as session: + user = SchemaUser(name="SchemaTest") + session.add(user) + session.commit() + + users = session.scalars( + select(SchemaUser).where(SchemaUser.name == "SchemaTest") + ).all() + eq_(1, len(users)) + is_true(users[0].ID > 0) + + session.execute( + update(SchemaUser) + .where(SchemaUser.name == "SchemaTest") + .values(name="NewName") + ) + session.commit() + + users = session.scalars( + select(SchemaUser).where(SchemaUser.name == "NewName") + ).all() + eq_(1, len(users)) + is_true(users[0].ID > 0) + + session.execute(delete(SchemaUser).where(SchemaUser.name == "NewName")) + session.commit() + + users = session.scalars( + select(SchemaUser).where(SchemaUser.name == "NewName") + ).all() + eq_(0, len(users)) diff --git a/test/test_suite_13.py b/test/test_suite_13.py index 97f5754e..9a0c0545 100644 --- a/test/test_suite_13.py +++ b/test/test_suite_13.py @@ -1014,22 +1014,6 @@ def test_fk_column_order(self): eq_(set(fkey1.get("referred_columns")), {"name", "id", "attr"}) eq_(set(fkey1.get("constrained_columns")), {"pname", "pid", "pattr"}) - @testing.requires.primary_key_constraint_reflection - def test_pk_column_order(self, connection): - """ - SPANNER OVERRIDE: - Emultor doesn't support returning pk sorted by ordinal value - of columns. - """ - insp = inspect(connection) - primary_key = insp.get_pk_constraint(self.tables.tb1.name) - exp = ( - ["id", "name", "attr"] - if bool(os.environ.get("SPANNER_EMULATOR_HOST")) - else ["name", "id", "attr"] - ) - eq_(primary_key.get("constrained_columns"), exp) - class RowFetchTest(_RowFetchTest): def test_row_w_scalar_select(self): diff --git a/test/test_suite_14.py b/test/test_suite_14.py index 57305157..e12267fa 100644 --- a/test/test_suite_14.py +++ b/test/test_suite_14.py @@ -858,22 +858,6 @@ def test_fk_column_order(self): eq_(set(fkey1.get("referred_columns")), {"name", "id", "attr"}) eq_(set(fkey1.get("constrained_columns")), {"pname", "pid", "pattr"}) - @testing.requires.primary_key_constraint_reflection - def test_pk_column_order(self, connection): - """ - SPANNER OVERRIDE: - Emultor doesn't support returning pk sorted by ordinal value - of columns. - """ - insp = inspect(connection) - primary_key = insp.get_pk_constraint(self.tables.tb1.name) - exp = ( - ["id", "name", "attr"] - if bool(os.environ.get("SPANNER_EMULATOR_HOST")) - else ["name", "id", "attr"] - ) - eq_(primary_key.get("constrained_columns"), exp) - @pytest.mark.skip("Spanner doesn't support quotes in table names.") class QuotedNameArgumentTest(_QuotedNameArgumentTest): diff --git a/test/test_suite_20.py b/test/test_suite_20.py index 4b6849e4..2b40d762 100644 --- a/test/test_suite_20.py +++ b/test/test_suite_20.py @@ -1539,22 +1539,6 @@ def test_fk_column_order(self, connection): eq_(set(fkey1.get("referred_columns")), {"name", "id", "attr"}) eq_(set(fkey1.get("constrained_columns")), {"pname", "pid", "pattr"}) - @testing.requires.primary_key_constraint_reflection - def test_pk_column_order(self, connection): - """ - SPANNER OVERRIDE: - Emultor doesn't support returning pk sorted by ordinal value - of columns. - """ - insp = inspect(connection) - primary_key = insp.get_pk_constraint(self.tables.tb1.name) - exp = ( - ["id", "name", "attr"] - if bool(os.environ.get("SPANNER_EMULATOR_HOST")) - else ["name", "id", "attr"] - ) - eq_(primary_key.get("constrained_columns"), exp) - @pytest.mark.skip("Spanner doesn't support quotes in table names.") class QuotedNameArgumentTest(_QuotedNameArgumentTest): diff --git a/version.py b/version.py index c11faefc..3e8796d4 100644 --- a/version.py +++ b/version.py @@ -4,4 +4,4 @@ # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd -__version__ = "1.10.0" +__version__ = "1.11.0"