Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,15 @@ def visit_create_index(
text += " STORING (%s)" % ", ".join(
[self.preparer.quote(c.name) for c in storing_columns]
)

if options.get("null_filtered", False):
text = re.sub(
r"(^\s*CREATE\s+(?:UNIQUE\s+)?)INDEX",
r"\1NULL_FILTERED INDEX",
text,
flags=re.IGNORECASE,
)

return text

def get_identity_options(self, identity_options):
Expand Down
75 changes: 75 additions & 0 deletions samples/null_filtered_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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, Index
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import mapped_column, DeclarativeBase, Mapped, Session

from sample_helper import run_sample

# Shows how to create a null-filtered index.
#
# A null-filtered index does not index NULL values. This is useful for
# maintaining smaller indexes over sparse columns.
# https://cloud.google.com/spanner/docs/secondary-indexes#null-indexing-disable


class Base(DeclarativeBase):
pass


class Singer(Base):
__tablename__ = "singers_with_null_filtered_index"
__table_args__ = (
Index("uq_null_filtered_name", "name", unique=True, spanner_null_filtered=True),
)

id: Mapped[str] = mapped_column(primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str | None]


def null_filtered_index_sample():
engine = create_engine(
"spanner:///projects/sample-project/"
"instances/sample-instance/"
"databases/sample-database",
echo=True,
)
Base.metadata.create_all(engine)

# We can create singers with a name of jdoe and NULL.
with Session(engine) as session:
session.add(Singer(name="jdoe"))
session.add(Singer(name=None))
session.commit()

# The unique index will stop us from adding another jdoe.
with Session(engine) as session:
session.add(Singer(name="jdoe"))
try:
session.commit()
except IntegrityError:
session.rollback()

# The index is null filtered, so we can still add another
# NULL name. The NULL values are not part of the index.
with Session(engine) as session:
session.add(Singer(name=None))
session.commit()


if __name__ == "__main__":
run_sample(null_filtered_index_sample)
37 changes: 37 additions & 0 deletions test/mockserver_tests/null_filtered_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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 Index
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"
__table_args__ = (
Index("idx_name", "name"),
Index("idx_uq_name", "name", unique=True),
Index("idx_null_filtered_name", "name", spanner_null_filtered=True),
Index(
"idx_uq_null_filtered_name", "name", unique=True, spanner_null_filtered=True
),
)

id: Mapped[str] = mapped_column(primary_key=True)
name: Mapped[str]
80 changes: 80 additions & 0 deletions test/mockserver_tests/test_null_filtered_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 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.testing import eq_, is_instance_of
from google.cloud.spanner_v1 import (
FixedSizePool,
ResultSet,
)
from test.mockserver_tests.mock_server_test_base import (
MockServerTestBase,
add_result,
)
from google.cloud.spanner_admin_database_v1 import UpdateDatabaseDdlRequest


class TestNullFilteredIndex(MockServerTestBase):
"""Ensure we emit correct DDL for not null filtered indexes."""

def test_create_table(self):
from test.mockserver_tests.null_filtered_index import Base

add_result(
"""SELECT true
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA="" AND TABLE_NAME="singers"
LIMIT 1
""",
ResultSet(),
)
add_result(
"""SELECT true
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA="" AND TABLE_NAME="albums"
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_(5, len(requests[0].statements))
eq_(
"CREATE TABLE singers (\n"
"\tid STRING(MAX) NOT NULL, \n"
"\tname STRING(MAX) NOT NULL\n"
") PRIMARY KEY (id)",
requests[0].statements[0],
)

# The order of the CREATE INDEX statements appears to be
# arbitrary, so we sort it for test consistency.
index_statements = sorted(requests[0].statements[1:])
eq_("CREATE INDEX idx_name ON singers (name)", index_statements[0])
eq_(
"CREATE NULL_FILTERED INDEX idx_null_filtered_name ON singers (name)",
index_statements[1],
)
eq_("CREATE UNIQUE INDEX idx_uq_name ON singers (name)", index_statements[2])
eq_(
"CREATE UNIQUE NULL_FILTERED INDEX "
"idx_uq_null_filtered_name ON singers (name)",
index_statements[3],
)