Skip to content

Commit

Permalink
Merge branch 'main' into stale-reads
Browse files Browse the repository at this point in the history
  • Loading branch information
olavloite committed Dec 9, 2024
2 parents 8379c74 + d2d72b6 commit 4bc69a0
Show file tree
Hide file tree
Showing 10 changed files with 380 additions and 8 deletions.
13 changes: 8 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,9 @@ ReadOnly transactions
~~~~~~~~~~~~~~~~~~~~~

By default, transactions produced by a Spanner connection are in
ReadWrite mode. However, some applications require an ability to grant
ReadOnly access to users/methods; for these cases Spanner dialect
ReadWrite mode. However, workloads that only read data perform better
if they use read-only transactions, as Spanner does not need to take
locks for the data that is read; for these cases, the Spanner dialect
supports the ``read_only`` execution option, which switches a connection
into ReadOnly mode:

Expand All @@ -354,11 +355,13 @@ into ReadOnly mode:
with engine.connect().execution_options(read_only=True) as connection:
connection.execute(select(["*"], from_obj=table)).fetchall()
Note that execution options are applied lazily - on the ``execute()``
method call, right before it.
See the `Read-only transaction sample
<https://github.com/googleapis/python-spanner-sqlalchemy/blob/-/samples/read_only_transaction_sample.py>`__
for a concrete example.

ReadOnly/ReadWrite mode of a connection can't be changed while a
transaction is in progress - first you must commit or rollback it.
transaction is in progress - you must commit or rollback the current
transaction before changing the mode.

Stale reads
~~~~~~~~~~~
Expand Down
11 changes: 11 additions & 0 deletions google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def reset_connection(dbapi_conn, connection_record, reset_state=None):
"BYTES": types.LargeBinary,
"DATE": types.DATE,
"DATETIME": types.DATETIME,
"FLOAT32": types.REAL,
"FLOAT64": types.Float,
"INT64": types.BIGINT,
"NUMERIC": types.NUMERIC(precision=38, scale=9),
Expand All @@ -101,6 +102,7 @@ def reset_connection(dbapi_conn, connection_record, reset_state=None):
types.LargeBinary: "BYTES(MAX)",
types.DATE: "DATE",
types.DATETIME: "DATETIME",
types.REAL: "FLOAT32",
types.Float: "FLOAT64",
types.BIGINT: "INT64",
types.DECIMAL: "NUMERIC",
Expand Down Expand Up @@ -540,9 +542,18 @@ class SpannerTypeCompiler(GenericTypeCompiler):
def visit_INTEGER(self, type_, **kw):
return "INT64"

def visit_DOUBLE(self, type_, **kw):
return "FLOAT64"

def visit_FLOAT(self, type_, **kw):
# Note: This was added before Spanner supported FLOAT32.
# Changing this now to generate a FLOAT32 would be a breaking change.
# Users therefore have to use REAL to generate a FLOAT32 column.
return "FLOAT64"

def visit_REAL(self, type_, **kw):
return "FLOAT32"

def visit_TEXT(self, type_, **kw):
return "STRING({})".format(type_.length or "MAX")

Expand Down
5 changes: 5 additions & 0 deletions samples/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ def stale_read(session):
_sample(session)


@nox.session()
def read_only_transaction(session):
_sample(session)


@nox.session()
def _all_samples(session):
_sample(session)
Expand Down
64 changes: 64 additions & 0 deletions samples/read_only_transaction_sample.py
Original file line number Diff line number Diff line change
@@ -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, Engine
from sqlalchemy.orm import Session

from sample_helper import run_sample
from model import Singer, Concert, Venue


# Shows how to execute a read-only transaction on Spanner using SQLAlchemy.
def read_only_transaction_sample():
engine = create_engine(
"spanner:///projects/sample-project/"
"instances/sample-instance/"
"databases/sample-database",
echo=True,
)
# First insert a few test rows that can be queried in a read-only transaction.
insert_test_data(engine)

# Create a session that uses a read-only transaction.
# Read-only transactions do not take locks, and are therefore preferred
# above read/write transactions for workloads that only read data on Spanner.
with Session(engine.execution_options(read_only=True)) as session:
print("Singers ordered by last name")
singers = session.query(Singer).order_by(Singer.last_name).all()
for singer in singers:
print("Singer: ", singer.full_name)

print()
print("Singers ordered by first name")
singers = session.query(Singer).order_by(Singer.first_name).all()
for singer in singers:
print("Singer: ", singer.full_name)


def insert_test_data(engine: Engine):
with Session(engine) as session:
session.add_all(
[
Singer(id=str(uuid.uuid4()), first_name="John", last_name="Doe"),
Singer(id=str(uuid.uuid4()), first_name="Jane", last_name="Doe"),
]
)
session.commit()


if __name__ == "__main__":
run_sample(read_only_transaction_sample)
30 changes: 30 additions & 0 deletions test/mockserver_tests/float32_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 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 String
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.types import REAL


class Base(DeclarativeBase):
pass


class Number(Base):
__tablename__ = "numbers"
number: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(30))
ln: Mapped[float] = mapped_column(REAL)
33 changes: 33 additions & 0 deletions test/mockserver_tests/read_only_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 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 String, BigInteger, Sequence, TextClause
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,
Sequence("singer_id"),
server_default=TextClause("GET_NEXT_SEQUENCE_VALUE(SEQUENCE singer_id)"),
primary_key=True,
)
name: Mapped[str] = mapped_column(String)
73 changes: 73 additions & 0 deletions test/mockserver_tests/test_float32.py
Original file line number Diff line number Diff line change
@@ -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.

from sqlalchemy.orm import Session
from sqlalchemy.testing import (
eq_,
is_instance_of,
is_false,
)
from google.cloud.spanner_v1 import (
BatchCreateSessionsRequest,
ExecuteSqlRequest,
ResultSet,
ResultSetStats,
BeginTransactionRequest,
CommitRequest,
TypeCode,
)
from test.mockserver_tests.mock_server_test_base import (
MockServerTestBase,
add_result,
)


class TestFloat32(MockServerTestBase):
def test_insert_data(self):
from test.mockserver_tests.float32_model import Number

update_count = ResultSet(
dict(
stats=ResultSetStats(
dict(
row_count_exact=1,
)
)
)
)
add_result(
"INSERT INTO numbers (number, name, ln) VALUES (@a0, @a1, @a2)",
update_count,
)

engine = self.create_engine()
with Session(engine) as session:
n1 = Number(number=1, name="One", ln=0.0)
session.add_all([n1])
session.commit()

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_(3, len(request.params))
eq_("1", request.params["a0"])
eq_("One", request.params["a1"])
eq_(0.0, request.params["a2"])
eq_(TypeCode.INT64, request.param_types["a0"].code)
eq_(TypeCode.STRING, request.param_types["a1"].code)
is_false("a2" in request.param_types)
1 change: 0 additions & 1 deletion test/mockserver_tests/test_quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ class TestQuickStart(MockServerTestBase):
def test_create_tables(self):
from test.mockserver_tests.quickstart_model import Base

# TODO: Fix the double quotes inside these SQL fragments.
add_result(
"""SELECT true
FROM INFORMATION_SCHEMA.TABLES
Expand Down
Loading

0 comments on commit 4bc69a0

Please sign in to comment.