Skip to content
This repository was archived by the owner on Mar 13, 2026. It is now read-only.

Commit e35a8a0

Browse files
authored
feat: support sequences (#336)
* feat: support sequences in sqlalchemy spanner * feat: remove unsupported test in 1.3 * feat: dummy commit for running build * feat: skip emulator tests * feat: skip emulator tests for sequences * feat: fix lint * feat: fix lint * feat: remove unused imports * feat: remove space * feat: fix lint * feat: fix lint * fix: lint * fix: lint * feat: remove unchanged tests and lint * docs: add comments
1 parent eb7a1af commit e35a8a0

5 files changed

Lines changed: 494 additions & 5 deletions

File tree

google/cloud/sqlalchemy_spanner/requirements.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def isolation_level(self):
8181

8282
@property
8383
def sequences(self):
84-
return exclusions.closed()
84+
return exclusions.open()
8585

8686
@property
8787
def temporary_tables(self):

google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
)
4141
from sqlalchemy.sql.default_comparator import operator_lookup
4242
from sqlalchemy.sql.operators import json_getitem_op
43+
from sqlalchemy.sql import expression
4344

4445
from google.cloud.spanner_v1.data_types import JsonObject
4546
from google.cloud import spanner_dbapi
@@ -173,6 +174,16 @@ def pre_exec(self):
173174
if priority is not None:
174175
self._dbapi_connection.connection.request_priority = priority
175176

177+
def fire_sequence(self, seq, type_):
178+
"""Builds a statement for fetching next value of the sequence."""
179+
return self._execute_scalar(
180+
(
181+
"SELECT GET_NEXT_SEQUENCE_VALUE(SEQUENCE %s)"
182+
% self.identifier_preparer.format_sequence(seq)
183+
),
184+
type_,
185+
)
186+
176187

177188
class SpannerIdentifierPreparer(IdentifierPreparer):
178189
"""Identifiers compiler.
@@ -343,6 +354,20 @@ def limit_clause(self, select, **kw):
343354
text += " OFFSET " + self.process(select._offset_clause, **kw)
344355
return text
345356

357+
def returning_clause(self, stmt, returning_cols, **kw):
358+
columns = [
359+
self._label_select_column(None, c, True, False, {})
360+
for c in expression._select_iterables(returning_cols)
361+
]
362+
363+
return "THEN RETURN " + ", ".join(columns)
364+
365+
def visit_sequence(self, seq, **kw):
366+
"""Builds a statement for fetching next value of the sequence."""
367+
return " GET_NEXT_SEQUENCE_VALUE(SEQUENCE %s)" % self.preparer.format_sequence(
368+
seq
369+
)
370+
346371

347372
class SpannerDDLCompiler(DDLCompiler):
348373
"""Spanner DDL statements compiler."""
@@ -457,6 +482,24 @@ def post_create_table(self, table):
457482

458483
return post_cmds
459484

485+
def get_identity_options(self, identity_options):
486+
text = ["sequence_kind = 'bit_reversed_positive'"]
487+
if identity_options.start is not None:
488+
text.append("start_with_counter = %d" % identity_options.start)
489+
return ", ".join(text)
490+
491+
def visit_create_sequence(self, create, prefix=None, **kw):
492+
"""Builds a ``CREATE SEQUENCE`` statement for the sequence."""
493+
text = "CREATE SEQUENCE %s" % self.preparer.format_sequence(create.element)
494+
options = self.get_identity_options(create.element)
495+
if options:
496+
text += " OPTIONS (" + options + ")"
497+
return text
498+
499+
def visit_drop_sequence(self, drop, **kw):
500+
"""Builds a ``DROP SEQUENCE`` statement for the sequence."""
501+
return "DROP SEQUENCE %s" % self.preparer.format_sequence(drop.element)
502+
460503

461504
class SpannerTypeCompiler(GenericTypeCompiler):
462505
"""Spanner types compiler.
@@ -531,7 +574,8 @@ class SpannerDialect(DefaultDialect):
531574
supports_sane_rowcount = False
532575
supports_sane_multi_rowcount = False
533576
supports_default_values = False
534-
supports_sequences = False
577+
supports_sequences = True
578+
sequences_optional = False
535579
supports_native_enum = True
536580
supports_native_boolean = True
537581
supports_native_decimal = True
@@ -694,6 +738,36 @@ def get_view_names(self, connection, schema=None, **kw):
694738

695739
return all_views
696740

741+
@engine_to_connection
742+
def get_sequence_names(self, connection, schema=None, **kw):
743+
"""
744+
Return a list of all sequence names available in the database.
745+
746+
The method is used by SQLAlchemy introspection systems.
747+
748+
Args:
749+
connection (sqlalchemy.engine.base.Connection):
750+
SQLAlchemy connection or engine object.
751+
schema (str): Optional. Schema name
752+
753+
Returns:
754+
list: List of sequence names.
755+
"""
756+
sql = """
757+
SELECT name
758+
FROM information_schema.sequences
759+
WHERE SCHEMA='{}'
760+
""".format(
761+
schema or ""
762+
)
763+
all_sequences = []
764+
with connection.connection.database.snapshot() as snap:
765+
rows = list(snap.execute_sql(sql))
766+
for seq in rows:
767+
all_sequences.append(seq[0])
768+
769+
return all_sequences
770+
697771
@engine_to_connection
698772
def get_view_definition(self, connection, view_name, schema=None, **kw):
699773
"""
@@ -1294,6 +1368,32 @@ def has_table(self, connection, table_name, schema=None, **kw):
12941368

12951369
return False
12961370

1371+
@engine_to_connection
1372+
def has_sequence(self, connection, sequence_name, schema=None, **kw):
1373+
"""Check the existence of a particular sequence in the database.
1374+
1375+
Given a :class:`_engine.Connection` object and a string
1376+
`sequence_name`, return True if the given sequence exists in
1377+
the database, False otherwise.
1378+
"""
1379+
1380+
with connection.connection.database.snapshot() as snap:
1381+
rows = snap.execute_sql(
1382+
"""
1383+
SELECT true
1384+
FROM INFORMATION_SCHEMA.SEQUENCES
1385+
WHERE NAME="{sequence_name}"
1386+
LIMIT 1
1387+
""".format(
1388+
sequence_name=sequence_name
1389+
)
1390+
)
1391+
1392+
for _ in rows:
1393+
return True
1394+
1395+
return False
1396+
12971397
def set_isolation_level(self, conn_proxy, level):
12981398
"""Set the connection isolation level.
12991399

test/test_suite_13.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from sqlalchemy.testing import config
3838
from sqlalchemy.testing import engines
3939
from sqlalchemy.testing import eq_
40+
from sqlalchemy.testing import is_instance_of
4041
from sqlalchemy.testing import provide_metadata, emits_warning
4142
from sqlalchemy.testing import fixtures
4243
from sqlalchemy.testing import is_true
@@ -73,7 +74,10 @@
7374
from sqlalchemy.testing.suite.test_reflection import * # noqa: F401, F403
7475
from sqlalchemy.testing.suite.test_results import * # noqa: F401, F403
7576
from sqlalchemy.testing.suite.test_select import * # noqa: F401, F403
76-
from sqlalchemy.testing.suite.test_sequence import * # noqa: F401, F403
77+
from sqlalchemy.testing.suite.test_sequence import (
78+
SequenceTest as _SequenceTest,
79+
HasSequenceTest as _HasSequenceTest,
80+
) # noqa: F401, F403
7781
from sqlalchemy.testing.suite.test_update_delete import * # noqa: F401, F403
7882

7983
from sqlalchemy.testing.suite.test_cte import CTETest as _CTETest
@@ -2059,3 +2063,124 @@ def test_create_engine_wo_database(self):
20592063
engine = create_engine(get_db_url().split("/database")[0])
20602064
with engine.connect() as connection:
20612065
assert connection.connection.database is None
2066+
2067+
2068+
@pytest.mark.skipif(
2069+
bool(os.environ.get("SPANNER_EMULATOR_HOST")), reason="Skipped on emulator"
2070+
)
2071+
class SequenceTest(_SequenceTest):
2072+
@classmethod
2073+
def define_tables(cls, metadata):
2074+
Table(
2075+
"seq_pk",
2076+
metadata,
2077+
Column(
2078+
"id",
2079+
Integer,
2080+
sqlalchemy.Sequence("tab_id_seq"),
2081+
primary_key=True,
2082+
),
2083+
Column("data", String(50)),
2084+
)
2085+
2086+
Table(
2087+
"seq_opt_pk",
2088+
metadata,
2089+
Column(
2090+
"id",
2091+
Integer,
2092+
sqlalchemy.Sequence("tab_id_seq_opt", data_type=Integer, optional=True),
2093+
primary_key=True,
2094+
),
2095+
Column("data", String(50)),
2096+
)
2097+
2098+
Table(
2099+
"seq_no_returning",
2100+
metadata,
2101+
Column(
2102+
"id",
2103+
Integer,
2104+
sqlalchemy.Sequence("noret_id_seq"),
2105+
primary_key=True,
2106+
),
2107+
Column("data", String(50)),
2108+
implicit_returning=False,
2109+
)
2110+
2111+
def test_insert_lastrowid(self, connection):
2112+
r = connection.execute(self.tables.seq_pk.insert(), dict(data="some data"))
2113+
assert len(r.inserted_primary_key) == 1
2114+
is_instance_of(r.inserted_primary_key[0], int)
2115+
2116+
def test_nextval_direct(self, connection):
2117+
r = connection.execute(self.tables.seq_pk.c.id.default)
2118+
is_instance_of(r, int)
2119+
2120+
def _assert_round_trip(self, table, conn):
2121+
row = conn.execute(table.select()).first()
2122+
id, name = row
2123+
is_instance_of(id, int)
2124+
eq_(name, "some data")
2125+
2126+
@testing.combinations((True,), (False,), argnames="implicit_returning")
2127+
@testing.requires.schemas
2128+
@pytest.mark.skip("Not supported by Cloud Spanner")
2129+
def test_insert_roundtrip_translate(self, connection, implicit_returning):
2130+
pass
2131+
2132+
@testing.requires.schemas
2133+
@pytest.mark.skip("Not supported by Cloud Spanner")
2134+
def test_nextval_direct_schema_translate(self, connection):
2135+
pass
2136+
2137+
2138+
@pytest.mark.skipif(
2139+
bool(os.environ.get("SPANNER_EMULATOR_HOST")), reason="Skipped on emulator"
2140+
)
2141+
class HasSequenceTest(_HasSequenceTest):
2142+
@classmethod
2143+
def define_tables(cls, metadata):
2144+
sqlalchemy.Sequence("user_id_seq", metadata=metadata)
2145+
sqlalchemy.Sequence(
2146+
"other_seq", metadata=metadata, nomaxvalue=True, nominvalue=True
2147+
)
2148+
Table(
2149+
"user_id_table",
2150+
metadata,
2151+
Column("id", Integer, primary_key=True),
2152+
)
2153+
2154+
@pytest.mark.skip("Not supported by Cloud Spanner")
2155+
def test_has_sequence_cache(self, connection, metadata):
2156+
pass
2157+
2158+
@testing.requires.schemas
2159+
@pytest.mark.skip("Not supported by Cloud Spanner")
2160+
def test_has_sequence_schema(self, connection):
2161+
pass
2162+
2163+
@testing.requires.schemas
2164+
@pytest.mark.skip("Not supported by Cloud Spanner")
2165+
def test_has_sequence_schemas_neg(self, connection):
2166+
pass
2167+
2168+
@testing.requires.schemas
2169+
@pytest.mark.skip("Not supported by Cloud Spanner")
2170+
def test_has_sequence_default_not_in_remote(self, connection):
2171+
pass
2172+
2173+
@testing.requires.schemas
2174+
@pytest.mark.skip("Not supported by Cloud Spanner")
2175+
def test_has_sequence_remote_not_in_default(self, connection):
2176+
pass
2177+
2178+
@testing.requires.schemas
2179+
@pytest.mark.skip("Not supported by Cloud Spanner")
2180+
def test_get_sequence_names_no_sequence_schema(self, connection):
2181+
pass
2182+
2183+
@testing.requires.schemas
2184+
@pytest.mark.skip("Not supported by Cloud Spanner")
2185+
def test_get_sequence_names_sequences_schema(self, connection):
2186+
pass

0 commit comments

Comments
 (0)