Skip to content

Commit 4bc0589

Browse files
authored
feat: Support NULL FILTERED indexes (#750)
Add support for the NULL FILTERED index option: https://cloud.google.com/spanner/docs/secondary-indexes#null-indexing-disable
1 parent 74a92ba commit 4bc0589

File tree

4 files changed

+201
-0
lines changed

4 files changed

+201
-0
lines changed

google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,15 @@ def visit_create_index(
718718
text += " STORING (%s)" % ", ".join(
719719
[self.preparer.quote(c.name) for c in storing_columns]
720720
)
721+
722+
if options.get("null_filtered", False):
723+
text = re.sub(
724+
r"(^\s*CREATE\s+(?:UNIQUE\s+)?)INDEX",
725+
r"\1NULL_FILTERED INDEX",
726+
text,
727+
flags=re.IGNORECASE,
728+
)
729+
721730
return text
722731

723732
def get_identity_options(self, identity_options):

samples/null_filtered_index.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
import uuid
16+
17+
from sqlalchemy import create_engine, Index
18+
from sqlalchemy.exc import IntegrityError
19+
from sqlalchemy.orm import mapped_column, DeclarativeBase, Mapped, Session
20+
21+
from sample_helper import run_sample
22+
23+
# Shows how to create a null-filtered index.
24+
#
25+
# A null-filtered index does not index NULL values. This is useful for
26+
# maintaining smaller indexes over sparse columns.
27+
# https://cloud.google.com/spanner/docs/secondary-indexes#null-indexing-disable
28+
29+
30+
class Base(DeclarativeBase):
31+
pass
32+
33+
34+
class Singer(Base):
35+
__tablename__ = "singers_with_null_filtered_index"
36+
__table_args__ = (
37+
Index("uq_null_filtered_name", "name", unique=True, spanner_null_filtered=True),
38+
)
39+
40+
id: Mapped[str] = mapped_column(primary_key=True, default=lambda: str(uuid.uuid4()))
41+
name: Mapped[str | None]
42+
43+
44+
def null_filtered_index_sample():
45+
engine = create_engine(
46+
"spanner:///projects/sample-project/"
47+
"instances/sample-instance/"
48+
"databases/sample-database",
49+
echo=True,
50+
)
51+
Base.metadata.create_all(engine)
52+
53+
# We can create singers with a name of jdoe and NULL.
54+
with Session(engine) as session:
55+
session.add(Singer(name="jdoe"))
56+
session.add(Singer(name=None))
57+
session.commit()
58+
59+
# The unique index will stop us from adding another jdoe.
60+
with Session(engine) as session:
61+
session.add(Singer(name="jdoe"))
62+
try:
63+
session.commit()
64+
except IntegrityError:
65+
session.rollback()
66+
67+
# The index is null filtered, so we can still add another
68+
# NULL name. The NULL values are not part of the index.
69+
with Session(engine) as session:
70+
session.add(Singer(name=None))
71+
session.commit()
72+
73+
74+
if __name__ == "__main__":
75+
run_sample(null_filtered_index_sample)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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 Index
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+
__table_args__ = (
28+
Index("idx_name", "name"),
29+
Index("idx_uq_name", "name", unique=True),
30+
Index("idx_null_filtered_name", "name", spanner_null_filtered=True),
31+
Index(
32+
"idx_uq_null_filtered_name", "name", unique=True, spanner_null_filtered=True
33+
),
34+
)
35+
36+
id: Mapped[str] = mapped_column(primary_key=True)
37+
name: Mapped[str]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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.testing import eq_, is_instance_of
17+
from google.cloud.spanner_v1 import (
18+
FixedSizePool,
19+
ResultSet,
20+
)
21+
from test.mockserver_tests.mock_server_test_base import (
22+
MockServerTestBase,
23+
add_result,
24+
)
25+
from google.cloud.spanner_admin_database_v1 import UpdateDatabaseDdlRequest
26+
27+
28+
class TestNullFilteredIndex(MockServerTestBase):
29+
"""Ensure we emit correct DDL for not null filtered indexes."""
30+
31+
def test_create_table(self):
32+
from test.mockserver_tests.null_filtered_index import Base
33+
34+
add_result(
35+
"""SELECT true
36+
FROM INFORMATION_SCHEMA.TABLES
37+
WHERE TABLE_SCHEMA="" AND TABLE_NAME="singers"
38+
LIMIT 1
39+
""",
40+
ResultSet(),
41+
)
42+
add_result(
43+
"""SELECT true
44+
FROM INFORMATION_SCHEMA.TABLES
45+
WHERE TABLE_SCHEMA="" AND TABLE_NAME="albums"
46+
LIMIT 1
47+
""",
48+
ResultSet(),
49+
)
50+
engine = create_engine(
51+
"spanner:///projects/p/instances/i/databases/d",
52+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
53+
)
54+
Base.metadata.create_all(engine)
55+
requests = self.database_admin_service.requests
56+
eq_(1, len(requests))
57+
is_instance_of(requests[0], UpdateDatabaseDdlRequest)
58+
eq_(5, len(requests[0].statements))
59+
eq_(
60+
"CREATE TABLE singers (\n"
61+
"\tid STRING(MAX) NOT NULL, \n"
62+
"\tname STRING(MAX) NOT NULL\n"
63+
") PRIMARY KEY (id)",
64+
requests[0].statements[0],
65+
)
66+
67+
# The order of the CREATE INDEX statements appears to be
68+
# arbitrary, so we sort it for test consistency.
69+
index_statements = sorted(requests[0].statements[1:])
70+
eq_("CREATE INDEX idx_name ON singers (name)", index_statements[0])
71+
eq_(
72+
"CREATE NULL_FILTERED INDEX idx_null_filtered_name ON singers (name)",
73+
index_statements[1],
74+
)
75+
eq_("CREATE UNIQUE INDEX idx_uq_name ON singers (name)", index_statements[2])
76+
eq_(
77+
"CREATE UNIQUE NULL_FILTERED INDEX "
78+
"idx_uq_null_filtered_name ON singers (name)",
79+
index_statements[3],
80+
)

0 commit comments

Comments
 (0)