Skip to content

Commit f67ebe8

Browse files
authored
feat: support AUTO_INCREMENT and IDENTITY columns (#610)
* feat: support AUTO_INCREMENT and IDENTITY columns Adds support for IDENTITY and AUTO_INCREMENT columns to the Spanner dialect. These are used by default for primary key generation. By default, IDENTITY columns using a backing bit-reversed sequence are used for primary key generation. The sequence kind to use can be configured by setting the attribute default_sequence_kind on the Spanner dialect. The use of AUTO_INCREMENT columns instead of IDENTITY can be configured by setting the use_auto_increment attribute on the Spanner dialect. * test: add system test + fix conformance 1.3 test * docs: add sample and update README * chore: minor cleanup
1 parent cbdaec7 commit f67ebe8

11 files changed

+393
-37
lines changed

README.rst

+17-23
Original file line numberDiff line numberDiff line change
@@ -293,29 +293,23 @@ This, however, may require to manually repeat a long list of operations, execute
293293

294294
In ``AUTOCOMMIT`` mode automatic transactions retry mechanism is disabled, as every operation is committed just in time, and there is no way an ``Aborted`` exception can happen.
295295

296-
Auto-incremented IDs
297-
~~~~~~~~~~~~~~~~~~~~
298-
299-
Cloud Spanner doesn't support autoincremented IDs mechanism due to
300-
performance reasons (`see for more
301-
details <https://cloud.google.com/spanner/docs/schema-design#primary-key-prevent-hotspots>`__).
302-
We recommend that you use the Python
303-
`uuid <https://docs.python.org/3/library/uuid.html>`__ module to
304-
generate primary key fields to avoid creating monotonically increasing
305-
keys.
306-
307-
Though it's not encouraged to do so, in case you *need* the feature, you
308-
can simulate it manually as follows:
309-
310-
.. code:: python
311-
312-
with engine.begin() as connection:
313-
top_id = connection.execute(
314-
select([user.c.user_id]).order_by(user.c.user_id.desc()).limit(1)
315-
).fetchone()
316-
next_id = top_id[0] + 1 if top_id else 1
317-
318-
connection.execute(user.insert(), {"user_id": next_id})
296+
Auto-increment primary keys
297+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
298+
299+
Spanner uses IDENTITY columns for auto-increment primary key values.
300+
IDENTITY columns use a backing bit-reversed sequence to generate unique
301+
values that are safe to use as primary values in Spanner. These values
302+
work the same as standard auto-increment values, except that they are
303+
not monotonically increasing. This prevents hot-spotting for tables that
304+
receive a large number of writes.
305+
306+
`See this documentation page for more details <https://cloud.google.com/spanner/docs/schema-design#primary-key-prevent-hotspots>`__.
307+
308+
Auto-generated primary keys must be returned by Spanner after each insert
309+
statement using a ``THEN RETURN`` clause. ``THEN RETURN`` clauses are not
310+
supported with `Batch DML <https://cloud.google.com/spanner/docs/dml-tasks#use-batch>`__.
311+
It is therefore recommended to use for example client-side generated UUIDs
312+
as primary key values instead.
319313

320314
Query hints
321315
~~~~~~~~~~~

google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

+35-4
Original file line numberDiff line numberDiff line change
@@ -409,11 +409,34 @@ def get_column_specification(self, column, **kwargs):
409409
if not column.nullable:
410410
colspec += " NOT NULL"
411411

412+
has_identity = (
413+
hasattr(column, "identity")
414+
and column.identity is not None
415+
and self.dialect.supports_identity_columns
416+
)
412417
default = self.get_column_default_string(column)
413-
if default is not None:
414-
colspec += " DEFAULT (" + default + ")"
415418

416-
if hasattr(column, "computed") and column.computed is not None:
419+
if (
420+
column.primary_key
421+
and column is column.table._autoincrement_column
422+
and not has_identity
423+
and default is None
424+
):
425+
if (
426+
hasattr(self.dialect, "use_auto_increment")
427+
and self.dialect.use_auto_increment
428+
):
429+
colspec += " AUTO_INCREMENT"
430+
else:
431+
sequence_kind = getattr(
432+
self.dialect, "default_sequence_kind", "BIT_REVERSED_POSITIVE"
433+
)
434+
colspec += " GENERATED BY DEFAULT AS IDENTITY (%s)" % sequence_kind
435+
elif has_identity:
436+
colspec += " " + self.process(column.identity)
437+
elif default is not None:
438+
colspec += " DEFAULT (" + default + ")"
439+
elif hasattr(column, "computed") and column.computed is not None:
417440
colspec += " " + self.process(column.computed)
418441

419442
return colspec
@@ -526,6 +549,12 @@ def visit_create_index(
526549
return text
527550

528551
def get_identity_options(self, identity_options):
552+
text = ["bit_reversed_positive"]
553+
if identity_options.start is not None:
554+
text.append("start counter with %d" % identity_options.start)
555+
return " ".join(text)
556+
557+
def get_sequence_options(self, identity_options):
529558
text = ["sequence_kind = 'bit_reversed_positive'"]
530559
if identity_options.start is not None:
531560
text.append("start_with_counter = %d" % identity_options.start)
@@ -534,7 +563,7 @@ def get_identity_options(self, identity_options):
534563
def visit_create_sequence(self, create, prefix=None, **kw):
535564
"""Builds a ``CREATE SEQUENCE`` statement for the sequence."""
536565
text = "CREATE SEQUENCE %s" % self.preparer.format_sequence(create.element)
537-
options = self.get_identity_options(create.element)
566+
options = self.get_sequence_options(create.element)
538567
if options:
539568
text += " OPTIONS (" + options + ")"
540569
return text
@@ -628,11 +657,13 @@ class SpannerDialect(DefaultDialect):
628657
supports_default_values = False
629658
supports_sequences = True
630659
sequences_optional = False
660+
supports_identity_columns = True
631661
supports_native_enum = True
632662
supports_native_boolean = True
633663
supports_native_decimal = True
634664
supports_statement_cache = True
635665

666+
postfetch_lastrowid = False
636667
insert_returning = True
637668
update_returning = True
638669
delete_returning = True
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright 2025 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from sqlalchemy import create_engine
16+
from sqlalchemy.orm import Session
17+
18+
from sample_helper import run_sample
19+
from model import Venue
20+
21+
22+
# Shows how to use an IDENTITY column for primary key generation. IDENTITY
23+
# columns use a backing bit-reversed sequence to generate unique values that are
24+
# safe to use for primary keys in Spanner.
25+
#
26+
# IDENTITY columns are used by default by the Spanner SQLAlchemy dialect for
27+
# standard primary key columns.
28+
#
29+
# id: Mapped[int] = mapped_column(primary_key=True)
30+
#
31+
# This leads to the following table definition:
32+
#
33+
# CREATE TABLE ticket_sales (
34+
# id INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE),
35+
# ...
36+
# ) PRIMARY KEY (id)
37+
def auto_generated_primary_key_sample():
38+
engine = create_engine(
39+
"spanner:///projects/sample-project/"
40+
"instances/sample-instance/"
41+
"databases/sample-database",
42+
echo=True,
43+
)
44+
45+
# Add a line like the following to use AUTO_INCREMENT instead of IDENTITY
46+
# when creating tables in SQLAlchemy.
47+
# https://cloud.google.com/spanner/docs/primary-key-default-value#serial-auto-increment
48+
49+
# engine.dialect.use_auto_increment = True
50+
# Base.metadata.create_all(engine)
51+
52+
with Session(engine) as session:
53+
# Venue automatically generates a primary key value using an IDENTITY
54+
# column. We therefore do not need to specify a primary key value when
55+
# we create an instance of Venue.
56+
venue = Venue(code="CH", name="Concert Hall", active=True)
57+
session.add_all([venue])
58+
session.commit()
59+
60+
print("Inserted a venue with ID %d" % venue.id)
61+
62+
63+
if __name__ == "__main__":
64+
run_sample(auto_generated_primary_key_sample)

samples/model.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@
3131
ForeignKeyConstraint,
3232
Sequence,
3333
TextClause,
34-
func,
35-
FetchedValue,
34+
Index,
3635
)
3736
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
3837

@@ -45,6 +44,10 @@ class Base(DeclarativeBase):
4544
# This allows inserts to use Batch DML, as the primary key value does not need
4645
# to be returned from Spanner using a THEN RETURN clause.
4746
#
47+
# The Venue model uses a standard auto-generated integer primary key. This uses
48+
# an IDENTITY column in Spanner. IDENTITY columns use a backing bit-reversed
49+
# sequence to generate unique values that are safe to use for primary keys.
50+
#
4851
# The TicketSale model uses a bit-reversed sequence for primary key generation.
4952
# This is achieved by creating a bit-reversed sequence and assigning the id
5053
# column of the model a server_default value that gets the next value from that
@@ -117,7 +120,11 @@ class Track(Base):
117120

118121
class Venue(Base):
119122
__tablename__ = "venues"
120-
code: Mapped[str] = mapped_column(String(10), primary_key=True)
123+
__table_args__ = (Index("venues_code_unique", "code", unique=True),)
124+
# Venue uses a standard auto-generated primary key.
125+
# This translates to an IDENTITY column in Spanner.
126+
id: Mapped[int] = mapped_column(primary_key=True)
127+
code: Mapped[str] = mapped_column(String(10))
121128
name: Mapped[str] = mapped_column(String(200), nullable=False)
122129
description: Mapped[str] = mapped_column(JSON, nullable=True)
123130
active: Mapped[bool] = mapped_column(Boolean, nullable=False)

samples/noxfile.py

+5
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ def hello_world(session):
2222
_sample(session)
2323

2424

25+
@nox.session()
26+
def auto_generated_primary_key(session):
27+
_sample(session)
28+
29+
2530
@nox.session()
2631
def bit_reversed_sequence(session):
2732
_sample(session)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright 2025 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from sqlalchemy import String
16+
from sqlalchemy.orm import DeclarativeBase
17+
from sqlalchemy.orm import Mapped
18+
from sqlalchemy.orm import mapped_column
19+
20+
21+
class Base(DeclarativeBase):
22+
pass
23+
24+
25+
class Singer(Base):
26+
__tablename__ = "singers"
27+
id: Mapped[int] = mapped_column(primary_key=True)
28+
name: Mapped[str] = mapped_column(String)

0 commit comments

Comments
 (0)