FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Merge pull request #173 from google/null-filtered-index · MetaOfX/python-spanner-orm@a268212 · GitHub

Commit a268212

Browse files
authored
Merge pull request google#173 from google/null-filtered-index
Add conditions to use NULL_FILTERED indexes safely
2 parents bc13627 + a1caa02 commit a268212

6 files changed

Lines changed: 163 additions & 12 deletions

File tree

‎spanner_orm/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
contains = condition.contains
8282
equal_to = condition.equal_to
8383
force_index = condition.force_index
84+
force_null_filtered_index = condition.force_null_filtered_index
8485
greater_than = condition.greater_than
8586
greater_than_or_equal_to = condition.greater_than_or_equal_to
8687
in_list = condition.in_list

‎spanner_orm/condition.py‎

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import decimal
2222
import enum
2323
import string
24-
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Type, TypeVar, Union
24+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Type, TypeVar, Union
2525

2626
from spanner_orm import error
2727
from spanner_orm import field
@@ -364,10 +364,10 @@ def _validate(self, model_class: Type[Any]) -> None:
364364
origin.name, dest.name))
365365

366366

367-
class ForceIndexCondition(Condition):
368-
"""Used to indicate which index should be used in a Spanner query."""
367+
class _IndexCondition(Condition):
368+
"""Base class for conditions based on an Index."""
369369

370-
def __init__(self, index_or_name: Union[Type[index.Index], str]):
370+
def __init__(self, index_or_name: Union[index.Index, str]):
371371
super().__init__()
372372
if isinstance(index_or_name, index.Index):
373373
self.name = index_or_name.name
@@ -380,30 +380,63 @@ def bind(self, model_class: Type[Any]) -> None:
380380
super().bind(model_class)
381381
self.index = self.model_class.indexes[self.name]
382382

383+
def _validate(self, model_class: Type[Any]) -> None:
384+
if self.name not in model_class.indexes:
385+
raise error.ValidationError('{} is not an index on {}'.format(
386+
self.name, model_class.table))
387+
if self.index and self.index != model_class.indexes[self.name]:
388+
raise error.ValidationError('{} does not belong to {}'.format(
389+
self.index.name, model_class.table))
390+
391+
392+
class ForceIndexCondition(_IndexCondition):
393+
"""Used to indicate which index should be used in a Spanner query."""
394+
395+
def __init__(
396+
self,
397+
index_or_name: Union[index.Index, str],
398+
*,
399+
extra_hints: Sequence[str] = (),
400+
):
401+
super().__init__(index_or_name)
402+
self._extra_hints = extra_hints
403+
383404
def _params(self) -> Dict[str, Any]:
384405
return {}
385406

386407
def segment(self) -> Segment:
387408
return Segment.FROM
388409

389410
def _sql(self) -> str:
390-
return '@{{FORCE_INDEX={}}}'.format(self.name)
411+
hints = (f'FORCE_INDEX={self.name}', *self._extra_hints)
412+
return f'@{{{",".join(hints)}}}'
391413

392414
def _types(self) -> Dict[str, type_pb2.Type]:
393415
return {}
394416

395417
def _validate(self, model_class: Type[Any]) -> None:
396-
if self.name not in model_class.indexes:
397-
raise error.ValidationError('{} is not an index on {}'.format(
398-
self.name, model_class.table))
399-
if self.index and self.index != model_class.indexes[self.name]:
400-
raise error.ValidationError('{} does not belong to {}'.format(
401-
self.index.name, model_class.table))
402-
418+
super()._validate(model_class)
403419
if model_class.indexes[self.name].primary:
404420
raise error.ValidationError('Cannot force query using primary index')
405421

406422

423+
class _IndexIgnoreNullsCondition(_IndexCondition):
424+
"""Condition to filter NULL values in any column of an index."""
425+
426+
def _params(self) -> Dict[str, Any]:
427+
return {}
428+
429+
def segment(self) -> Segment:
430+
return Segment.WHERE
431+
432+
def _sql(self) -> str:
433+
return '({})'.format(' AND '.join(
434+
f'{column} IS NOT NULL' for column in self.index.columns))
435+
436+
def _types(self) -> Dict[str, type_pb2.Type]:
437+
return {}
438+
439+
407440
class IncludesCondition(Condition):
408441
"""Used to include related model_classs via a relation in a Spanner query."""
409442

@@ -888,6 +921,35 @@ def force_index(forced_index: Union[index.Index, str]) -> ForceIndexCondition:
888921
return ForceIndexCondition(forced_index)
889922

890923

924+
def force_null_filtered_index(
925+
forced_index: Union[index.Index, str]) -> Sequence[Condition]:
926+
"""Returns conditions to force the query to use the given NULL_FILTERED index.
927+
928+
In Cloud Spanner, a query against a NULL_FILTERED index is tested to see if it
929+
can use safely use that index. If using the index would result in incorrect
930+
results (e.g., by ignoring NULL values that would be in the same query without
931+
using the index), it's an error. However, the Cloud Spanner Emulator
932+
doesn't support that check:
933+
https://github.com/GoogleCloudPlatform/cloud-spanner-emulator/blob/e887ff5569684e6e45ce7c90d0fdfb7b1faa1491/common/errors.cc#L1790-L1800
934+
935+
For queries that can safely ignore any NULL values covered by the index, this
936+
function returns conditions that both filter out all relevant NULLs (avoiding
937+
the potential error in Cloud Spanner) and disable the check in Cloud Spanner
938+
Emulator.
939+
940+
Args:
941+
forced_index: NULL_FILTERED index to use.
942+
"""
943+
return (
944+
ForceIndexCondition(
945+
forced_index,
946+
extra_hints=(
947+
'spanner_emulator.disable_query_null_filtered_index_check=true',
948+
)),
949+
_IndexIgnoreNullsCondition(forced_index),
950+
)
951+
952+
891953
def greater_than(column: Union[field.Field, str],
892954
value: Any) -> ComparisonCondition:
893955
"""Condition where the specified column is greater than the given value.

‎spanner_orm/tests/condition_test.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,16 @@ def test_contains(
333333
)),
334334
)
335335

336+
def test_force_null_filtered_index(self):
337+
non_null_model = models.NullFilteredIndexModel(
338+
dict(key='a', value_1='a', value_2=1))
339+
non_null_model.save()
340+
models.NullFilteredIndexModel(dict(key='b', value_1=None, value_2=2)).save()
341+
self.assertCountEqual((non_null_model,),
342+
models.NullFilteredIndexModel.where(
343+
*spanner_orm.force_null_filtered_index(
344+
models.NullFilteredIndexModel.value_index)))
345+
336346

337347
if __name__ == '__main__':
338348
logging.basicConfig()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright 2022 Google LLC
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+
# https://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+
"""Spanner ORM migration: create_null_filtered_index_model.
15+
16+
Migration ID: '760ec5fae5da'
17+
Created: 2022-03-01 16:50:32-05:00
18+
"""
19+
20+
import spanner_orm
21+
22+
migration_id = '760ec5fae5da'
23+
prev_migration_id = 'f735d6b706d4'
24+
25+
26+
class _NullFilteredIndexModel(spanner_orm.Model):
27+
__table__ = 'NullFilteredIndexModel'
28+
key = spanner_orm.Field(spanner_orm.String, primary_key=True)
29+
value_1 = spanner_orm.Field(spanner_orm.String, nullable=True)
30+
value_2 = spanner_orm.Field(spanner_orm.Integer)
31+
32+
33+
def upgrade() -> spanner_orm.MigrationUpdate:
34+
"""See spanner_orm migrations interface."""
35+
return spanner_orm.CreateTable(_NullFilteredIndexModel)
36+
37+
38+
def downgrade() -> spanner_orm.MigrationUpdate:
39+
"""See spanner_orm migrations interface."""
40+
return spanner_orm.DropTable(_NullFilteredIndexModel.__table__)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Spanner ORM migration: create_null_filtered_index_model_value_index.
2+
3+
Migration ID: '69a8f072dacf'
4+
Created: 2022-03-01 16:53:59-05:00
5+
"""
6+
7+
import spanner_orm
8+
9+
migration_id = '69a8f072dacf'
10+
prev_migration_id = '760ec5fae5da'
11+
12+
13+
def upgrade() -> spanner_orm.MigrationUpdate:
14+
"""See spanner_orm migrations interface."""
15+
return spanner_orm.CreateIndex(
16+
table_name='NullFilteredIndexModel',
17+
index_name='value_index',
18+
columns=['value_1', 'value_2'],
19+
null_filtered=True,
20+
)
21+
22+
23+
def downgrade() -> spanner_orm.MigrationUpdate:
24+
"""See spanner_orm migrations interface."""
25+
return spanner_orm.DropIndex(
26+
table_name='NullFilteredIndexModel',
27+
index_name='value_index',
28+
)

‎spanner_orm/tests/models.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,13 @@ class UnittestModelWithoutSecondaryIndexes(model.Model):
130130
bytes_2 = field.Field(field.BytesBase64, nullable=True)
131131
timestamp = field.Field(field.Timestamp)
132132
string_array = field.Field(field.StringArray, nullable=True)
133+
134+
135+
class NullFilteredIndexModel(model.Model):
136+
"""Model class for testing NULL_FILTERED indexes."""
137+
138+
__table__ = 'NullFilteredIndexModel'
139+
key = field.Field(field.String, primary_key=True)
140+
value_1 = field.Field(field.String, nullable=True)
141+
value_2 = field.Field(field.Integer)
142+
value_index = index.Index(['value_1', 'value_2'], null_filtered=True)

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL