diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index 0f219787..b15150b2 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -53,6 +53,22 @@ jobs: - name: Run mockserver tests run: nox -s mockserver + samples: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Install nox + run: python -m pip install nox + - name: Run samples + run: nox -s _all_samples + working-directory: samples + compliance_tests_13: runs-on: ubuntu-latest diff --git a/README.rst b/README.rst index 25cde459..5c39e139 100644 --- a/README.rst +++ b/README.rst @@ -58,6 +58,13 @@ Next install the package from the package ``setup.py`` file: During setup the dialect will be registered with entry points. +Samples +------------- + +The `samples directory `__ +contains multiple examples for how to configure and use common Spanner features. + + A Minimal App ------------- diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 00000000..7ebf7e9e --- /dev/null +++ b/samples/README.md @@ -0,0 +1,30 @@ +# Spanner SQLAlchemy Samples + +This folder contains samples for how to use common Spanner features with SQLAlchemy. The samples use +a shared [data model](model.py) and can be executed as a standalone application. The samples +automatically start the [Spanner Emulator](https://cloud.google.com/spanner/docs/emulator) in a +Docker container when they are executed. You must therefore have Docker installed on your system to +run a sample. + +You can run a sample with `nox`: + +```shell +nox -s hello_world +``` + +Change `hello_world` to run any of the other sample names. The runnable samples all end with +`_sample.py`. Omit the `_sample.py` part of the file name to run the sample. + + + +| Sample name | Description | +|-----------------------|-----------------------------------------------------------------------------| +| bit_reversed_sequence | Use a bit-reversed sequence for primary key generation. | +| date_and_timestamp | Map Spanner DATE and TIMESTAMP columns to SQLAlchemy. | +| default_column_value | Create and use a Spanner DEFAULT column constraint in SQLAlchemy. | +| generated_column | Create and use a Spanner generated column in SQLAlchemy. | +| hello_world | Shows how to connect to Spanner with SQLAlchemy and execute a simple query. | +| insert_data | Insert multiple rows to Spanner with SQLAlchemy. | +| interleaved_table | Create and use an interleaved table (INTERLEAVE IN PARENT) with SQLAlchemy. | +| transaction | Execute a read/write transaction on Spanner with SQLAlchemy. | + diff --git a/samples/bit_reversed_sequence_sample.py b/samples/bit_reversed_sequence_sample.py new file mode 100644 index 00000000..bd7d80ce --- /dev/null +++ b/samples/bit_reversed_sequence_sample.py @@ -0,0 +1,70 @@ +# Copyright 2024 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 datetime +import uuid + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from sample_helper import run_sample +from model import Singer, Concert, Venue, TicketSale + + +# Shows how to use a bit-reversed sequence for primary key generation. +# +# The TicketSale model uses a bit-reversed sequence for automatic primary key +# generation: +# +# id: Mapped[int] = mapped_column( +# BigInteger, +# Sequence("ticket_sale_id"), +# server_default=TextClause("GET_NEXT_SEQUENCE_VALUE(SEQUENCE ticket_sale_id)"), +# primary_key=True, +# ) +# +# This leads to the following table definition: +# +# CREATE TABLE ticket_sales ( +# id INT64 NOT NULL DEFAULT (GET_NEXT_SEQUENCE_VALUE(SEQUENCE ticket_sale_id)), +# ... +# ) PRIMARY KEY (id) +def bit_reversed_sequence_sample(): + 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="Doe") + venue = Venue(code="CH", name="Concert Hall", active=True) + concert = Concert( + venue=venue, + start_time=datetime.datetime(2024, 11, 7, 19, 30, 0), + singer=singer, + title="John Doe - Live in Concert Hall", + ) + # TicketSale automatically generates a primary key value using a + # bit-reversed sequence. We therefore do not need to specify a primary + # key value when we create an instance of TicketSale. + ticket_sale = TicketSale( + concert=concert, customer_name="Alice Doe", seats=["A010", "A011", "A012"] + ) + session.add_all([singer, venue, concert, ticket_sale]) + session.commit() + + +if __name__ == "__main__": + run_sample(bit_reversed_sequence_sample) diff --git a/samples/date_and_timestamp_sample.py b/samples/date_and_timestamp_sample.py new file mode 100644 index 00000000..442ec611 --- /dev/null +++ b/samples/date_and_timestamp_sample.py @@ -0,0 +1,64 @@ +# Copyright 2024 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 datetime +import uuid + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from sample_helper import run_sample +from model import Singer, Concert, Venue + + +# Shows how to map and use the DATE and TIMESTAMP data types in Spanner. +def date_and_timestamp_sample(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + echo=True, + ) + with Session(engine) as session: + # Singer has a property birthdate, which is mapped to a DATE column. + # Use the datetime.date type for this. + singer = Singer( + id=str(uuid.uuid4()), + first_name="John", + last_name="Doe", + birthdate=datetime.date(1979, 10, 14), + ) + venue = Venue(code="CH", name="Concert Hall", active=True) + # Concert has a property `start_time`, which is mapped to a TIMESTAMP + # column. Use the datetime.datetime type for this. + concert = Concert( + venue=venue, + start_time=datetime.datetime(2024, 11, 7, 19, 30, 0), + singer=singer, + title="John Doe - Live in Concert Hall", + ) + session.add_all([singer, venue, concert]) + 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"{singer.full_name}, born on {singer.birthdate}, has planned " + f"a concert that starts on {concert.start_time} in {venue.name}." + ) + + +if __name__ == "__main__": + run_sample(date_and_timestamp_sample) diff --git a/samples/default_column_value_sample.py b/samples/default_column_value_sample.py new file mode 100644 index 00000000..82ecf566 --- /dev/null +++ b/samples/default_column_value_sample.py @@ -0,0 +1,61 @@ +# Copyright 2024 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 datetime +import uuid + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from sample_helper import run_sample +from model import Singer, Album, Track + + +# Shows how to use a default column with SQLAlchemy and Spanner. +def default_column_value_sample(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + echo=True, + ) + with Session(engine) as session: + # The Track model has a `recorded_at` property that is set to + # CURRENT_TIMESTAMP if no other value is supplied. + singer = Singer(id=str(uuid.uuid4()), first_name="John", last_name="Doe") + album = Album(id=str(uuid.uuid4()), title="My album", singer=singer) + + # This track will use the default CURRENT_TIMESTAMP for the recorded_at + # property. + track1 = Track( + id=str(uuid.uuid4()), + track_number=1, + title="My track 1", + album=album, + ) + track2 = Track( + id=str(uuid.uuid4()), + track_number=2, + title="My track 2", + recorded_at=datetime.datetime(2024, 11, 7, 10, 0, 0), + album=album, + ) + session.add_all([singer, album, track1, track2]) + session.commit() + print(f"Track 1 was recorded at: " f"{track1.recorded_at}") + print(f"Track 2 was recorded at: " f"{track2.recorded_at}") + + +if __name__ == "__main__": + run_sample(default_column_value_sample) diff --git a/samples/generated_column_sample.py b/samples/generated_column_sample.py new file mode 100644 index 00000000..fe6b157b --- /dev/null +++ b/samples/generated_column_sample.py @@ -0,0 +1,50 @@ +# Copyright 2024 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 a generated column with SQLAlchemy and Spanner. +def generated_column_sample(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + echo=True, + ) + with Session(engine) as session: + # The Singer model has a `full_name` property that is generated by the + # database. + singer = Singer(id=str(uuid.uuid4()), first_name="John", last_name="Doe") + session.add(singer) + session.commit() + print( + f"The database generated a full name for the singer: " f"{singer.full_name}" + ) + + # Updating the first name or last name of the singer will also update + # the generated full name property. + singer.last_name = "Jones" + session.commit() + print(f"Updated full name for singer: " f"{singer.full_name}") + + +if __name__ == "__main__": + run_sample(generated_column_sample) diff --git a/samples/hello_world_sample.py b/samples/hello_world_sample.py new file mode 100644 index 00000000..57f676c1 --- /dev/null +++ b/samples/hello_world_sample.py @@ -0,0 +1,31 @@ +# Copyright 2024 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, select, text +from sample_helper import run_sample + + +def quickstart(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database" + ) + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as connection: + results = connection.execute(select(text("'Hello World!'"))).fetchall() + print("\nMessage from Spanner: ", results[0][0], "\n") + + +if __name__ == "__main__": + run_sample(quickstart) diff --git a/samples/insert_data_sample.py b/samples/insert_data_sample.py new file mode 100644 index 00000000..a415e621 --- /dev/null +++ b/samples/insert_data_sample.py @@ -0,0 +1,73 @@ +# Copyright 2024 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, Album, Track + +# Shows how to insert data using SQLAlchemy, including relationships that are +# defined both as foreign keys and as interleaved tables. +def insert_data(): + 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", + albums=[ + Album( + id=str(uuid.uuid4()), + title="Rainforest", + tracks=[ + # Track is INTERLEAVED IN PARENT Album, but can be treated + # as a normal relationship in SQLAlchemy. + Track(track_number=1, title="Green"), + Track(track_number=2, title="Blue"), + Track(track_number=3, title="Yellow"), + ], + ), + Album( + id=str(uuid.uuid4()), + title="Butterflies", + tracks=[ + Track(track_number=1, title="Purple"), + Track(track_number=2, title="Cyan"), + Track(track_number=3, title="Mauve"), + ], + ), + ], + ) + 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} with {len(singer.albums)} " + f"albums successfully" + ) + + +if __name__ == "__main__": + run_sample(insert_data) diff --git a/samples/interleaved_table_sample.py b/samples/interleaved_table_sample.py new file mode 100644 index 00000000..0d652159 --- /dev/null +++ b/samples/interleaved_table_sample.py @@ -0,0 +1,98 @@ +# Copyright 2024 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, Album, Track + +# Shows how INTERLEAVE IN PARENT can be used in SQLAlchemy. +# INTERLEAVE IN PARENT can be modelled as if it were a normal relationship +# in SQLAlchemy. SQLAlchemy can also generate the correct DDL for this. +# +# This sample uses the following table structure: +# CREATE TABLE albums ( +# id STRING(36) NOT NULL, +# title STRING(200) NOT NULL, +# release_date DATE, +# singer_id STRING(36) NOT NULL, +# FOREIGN KEY(singer_id) REFERENCES singers (id) +# ) PRIMARY KEY (id); +# +# CREATE TABLE tracks ( +# id STRING(36) NOT NULL, +# track_number INT64 NOT NULL, +# title STRING(200) NOT NULL, +# duration NUMERIC +# ) PRIMARY KEY (id, track_number), +# INTERLEAVE IN PARENT albums ON DELETE CASCADE +# +# See model.py for the full model definitions. +def interleaved_table(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + echo=True, + ) + with Session(engine) as session: + # Insert a Singer row, two Albums, and six Tracks. + # Track is INTERLEAVED IN PARENT Album. + singer = Singer( + id=str(uuid.uuid4()), + first_name="John", + last_name="Smith", + albums=[ + Album( + id=str(uuid.uuid4()), + title="Rainforest", + tracks=[ + # Track is INTERLEAVED IN PARENT Album, but can be treated + # as a normal relationship in SQLAlchemy. + Track(track_number=1, title="Green"), + Track(track_number=2, title="Blue"), + Track(track_number=3, title="Yellow"), + ], + ), + Album( + id=str(uuid.uuid4()), + title="Butterflies", + tracks=[ + Track(track_number=1, title="Purple"), + Track(track_number=2, title="Cyan"), + Track(track_number=3, title="Mauve"), + ], + ), + ], + ) + 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"}) + # We can iterate over the tracks of an album as if it were a normal + # relationship. + print(f"Singer {singer.full_name} has these albums:") + for album in singer.albums: + print(f"\tAlbum {album.title} has these tracks:") + for track in album.tracks: + print(f"\t\t{track.track_number} - {track.title}") + + +if __name__ == "__main__": + run_sample(interleaved_table) diff --git a/samples/model.py b/samples/model.py new file mode 100644 index 00000000..13a4c83d --- /dev/null +++ b/samples/model.py @@ -0,0 +1,167 @@ +# Copyright 2024 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 datetime +from typing import Optional, List + +from sqlalchemy import ( + String, + Computed, + Date, + LargeBinary, + Integer, + Numeric, + ForeignKey, + JSON, + Boolean, + DateTime, + BigInteger, + ARRAY, + ForeignKeyConstraint, + Sequence, + TextClause, + func, + FetchedValue, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +# Most models in this sample use a client-side generated UUID as primary key. +# This allows inserts to use Batch DML, as the primary key value does not need +# to be returned from Spanner using a THEN RETURN clause. +# +# The TicketSale model uses a bit-reversed sequence for primary key generation. +# This is achieved by creating a bit-reversed sequence and assigning the id +# column of the model a server_default value that gets the next value from that +# sequence. + + +class Singer(Base): + __tablename__ = "singers" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + first_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + last_name: Mapped[str] = mapped_column(String(200), nullable=False) + full_name: Mapped[str] = mapped_column( + String, Computed("COALESCE(first_name || ' ', '') || last_name") + ) + birthdate: Mapped[Optional[datetime.date]] = mapped_column(Date, nullable=True) + picture: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True) + albums: Mapped[List["Album"]] = relationship( + back_populates="singer", cascade="all, delete-orphan" + ) + concerts: Mapped[List["Concert"]] = relationship( + back_populates="singer", cascade="all, delete-orphan" + ) + + +class Album(Base): + __tablename__ = "albums" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + release_date: Mapped[Optional[datetime.date]] = mapped_column(Date, nullable=True) + singer_id: Mapped[str] = mapped_column(ForeignKey("singers.id")) + singer: Mapped["Singer"] = relationship(back_populates="albums") + tracks: Mapped[List["Track"]] = relationship( + back_populates="album", + primaryjoin="Album.id == foreign(Track.id)", + order_by="Track.track_number", + ) + + +class Track(Base): + __tablename__ = "tracks" + # This interleaves the table `tracks` in its parent `albums`. + __table_args__ = { + "spanner_interleave_in": "albums", + "spanner_interleave_on_delete_cascade": True, + } + id: Mapped[str] = mapped_column(String(36), primary_key=True) + track_number: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + duration: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) + recorded_at: Mapped[Optional[datetime.datetime]] = mapped_column( + DateTime, + nullable=True, + # TODO: Enable this once 'func.now()' is mapped to CURRENT_TIMESTAMP + # server_default=func.now(), + server_default=TextClause("CURRENT_TIMESTAMP"), + ) + album: Mapped["Album"] = relationship( + back_populates="tracks", + foreign_keys=[id], + primaryjoin="Track.id == Album.id", + remote_side="Album.id", + ) + + +# SQLAlchemy does not know what 'spanner_interleave_in' means, so we need to +# explicitly tell SQLAlchemy that `tracks` depends on `albums`, and that +# `albums` therefore must be created before `tracks`. +Track.__table__.add_is_dependent_on(Album.__table__) + + +class Venue(Base): + __tablename__ = "venues" + code: Mapped[str] = mapped_column(String(10), primary_key=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str] = mapped_column(JSON, nullable=True) + active: Mapped[bool] = mapped_column(Boolean, nullable=False) + concerts: Mapped[List["Concert"]] = relationship( + back_populates="venue", cascade="all, delete-orphan" + ) + + +class Concert(Base): + __tablename__ = "concerts" + venue_code: Mapped[str] = mapped_column( + String(10), ForeignKey("venues.code"), primary_key=True + ) + start_time: Mapped[Optional[datetime.datetime]] = mapped_column( + DateTime, primary_key=True, nullable=False + ) + singer_id: Mapped[str] = mapped_column( + String(36), ForeignKey("singers.id"), primary_key=True + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + singer: Mapped["Singer"] = relationship(back_populates="concerts") + venue: Mapped["Venue"] = relationship(back_populates="concerts") + ticket_sales: Mapped[List["TicketSale"]] = relationship(back_populates="concert") + + +class TicketSale(Base): + __tablename__ = "ticket_sales" + __table_args__ = ( + ForeignKeyConstraint( + ["venue_code", "start_time", "singer_id"], + ["concerts.venue_code", "concerts.start_time", "concerts.singer_id"], + ), + ) + id: Mapped[int] = mapped_column( + BigInteger, + Sequence("ticket_sale_id"), + server_default=TextClause("GET_NEXT_SEQUENCE_VALUE(SEQUENCE ticket_sale_id)"), + primary_key=True, + ) + customer_name: Mapped[str] = mapped_column(String(200), nullable=False) + seats: Mapped[list[str]] = mapped_column(ARRAY(String(20)), nullable=False) + concert: Mapped["Concert"] = relationship(back_populates="ticket_sales") + venue_code: Mapped[str] = mapped_column(String(10), ForeignKey("venues.code")) + start_time: Mapped[Optional[datetime.datetime]] = mapped_column( + DateTime, nullable=False + ) + singer_id: Mapped[str] = mapped_column(String(36), ForeignKey("singers.id")) diff --git a/samples/noxfile.py b/samples/noxfile.py new file mode 100644 index 00000000..b103fd77 --- /dev/null +++ b/samples/noxfile.py @@ -0,0 +1,80 @@ +# Copyright 2024 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 os import listdir +from os.path import isfile, join + +import nox + + +@nox.session() +def hello_world(session): + _sample(session) + + +@nox.session() +def bit_reversed_sequence(session): + _sample(session) + + +@nox.session() +def date_and_timestamp(session): + _sample(session) + + +@nox.session() +def default_column_value(session): + _sample(session) + + +@nox.session() +def generated_column(session): + _sample(session) + + +@nox.session() +def insert_data(session): + _sample(session) + + +@nox.session() +def interleaved_table(session): + _sample(session) + + +@nox.session() +def transaction(session): + _sample(session) + + +@nox.session() +def _all_samples(session): + _sample(session) + + +def _sample(session): + session.install("testcontainers") + session.install("sqlalchemy") + session.install("setuptools") + session.install( + "git+https://github.com/googleapis/python-spanner.git#egg=google-cloud-spanner" + ) + session.install("../.") + if session.name == "_all_samples": + files = [ + f for f in listdir(".") if isfile(join(".", f)) and f.endswith("_sample.py") + ] + for file in files: + session.run("python", file) + else: + session.run("python", session.name + "_sample.py") diff --git a/samples/sample_helper.py b/samples/sample_helper.py new file mode 100644 index 00000000..862d535d --- /dev/null +++ b/samples/sample_helper.py @@ -0,0 +1,86 @@ +# Copyright 2024 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 os +from typing import Callable + +from google.api_core.client_options import ClientOptions +from google.auth.credentials import AnonymousCredentials +from google.cloud.spanner_v1 import Client +from sqlalchemy import create_engine +from sqlalchemy.dialects import registry +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + +from model import Base + + +def run_sample(sample_method: Callable): + registry.register( + "spanner", + "google.cloud.sqlalchemy_spanner.sqlalchemy_spanner", + "SpannerDialect", + ) + os.environ["SPANNER_EMULATOR_HOST"] = "" + emulator, port = start_emulator() + os.environ["SPANNER_EMULATOR_HOST"] = "localhost:" + str(port) + try: + _create_tables() + sample_method() + finally: + if emulator is not None: + emulator.stop() + + +def start_emulator() -> (DockerContainer, str): + emulator = DockerContainer( + "gcr.io/cloud-spanner-emulator/emulator" + ).with_exposed_ports(9010) + emulator.start() + wait_for_logs(emulator, "gRPC server listening at 0.0.0.0:9010") + port = emulator.get_exposed_port(9010) + _create_instance_and_database(port) + return emulator, port + + +def _create_instance_and_database(port: str): + client = Client( + project="sample-project", + credentials=AnonymousCredentials(), + client_options=ClientOptions( + api_endpoint="localhost:" + port, + ), + ) + configs = list(client.list_instance_configs()) + instance_config = configs[0].name + instance_id = "sample-instance" + database_id = "sample-database" + + instance = client.instance(instance_id, instance_config) + created_op = instance.create() + created_op.result(1800) # block until completion + + database = instance.database(database_id) + created_op = database.create() + created_op.result(1800) + + +def _create_tables(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + echo=True, + ) + Base.metadata.create_all(engine) diff --git a/samples/transaction_sample.py b/samples/transaction_sample.py new file mode 100644 index 00000000..b1f42ede --- /dev/null +++ b/samples/transaction_sample.py @@ -0,0 +1,82 @@ +# Copyright 2024 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 datetime +import uuid + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from sample_helper import run_sample +from model import Singer, Concert, Venue + + +# Shows how to execute a read/write transaction on Spanner using SQLAlchemy. +def transaction_sample(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + echo=True, + ) + with Session(engine) as session: + # SQLAlchemy by default automatically starts a transaction the first + # time the database is accessed. + singer_id = str(uuid.uuid4()) + singer = Singer(id=singer_id, first_name="John", last_name="Doe") + session.add(singer) + # Flush the session. This pushes all changes in this session to the + # database without committing the current transaction. + session.flush([singer]) + + # Verify that we can read this singer from the database during the + # same transaction. The Singer model has a `full_name` property that is + # generated by the database. This will be read from the database when + # we call refresh. + session.refresh(singer) + print( + f"The database generated a full name for the singer: " f"{singer.full_name}" + ) + + # We cannot read the singer in a different session, as the current + # transaction has not yet committed. + # We use AUTOCOMMIT for the additional session, as we do not need a + # read/write transaction for just trying to read one row. + with Session( + engine.execution_options(isolation_level="AUTOCOMMIT") + ) as session2: + # singer2 will be None, as the row will not be found. + singer2 = session2.get(Singer, singer_id) + print( + f"Fetching singer in a different transaction before the " + f"transaction was committed: {singer2}" + ) + + session.commit() + + # Now that the original transaction has committed, we can read the + # singer in a different session. + with Session( + engine.execution_options(isolation_level="AUTOCOMMIT") + ) as session2: + # singer2 will now return the actual row. + singer2 = session2.get(Singer, singer_id) + print( + f"Fetching singer in a different transaction after the " + f"transaction was committed: {singer2.full_name}" + ) + + +if __name__ == "__main__": + run_sample(transaction_sample)