| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
6 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -81,6 +81,7 @@ | |||
| 81 | 81 | contains = condition.contains | |
| 82 | 82 | equal_to = condition.equal_to | |
| 83 | 83 | force_index = condition.force_index | |
| 84 | + force_null_filtered_index = condition.force_null_filtered_index | ||
| 84 | 85 | greater_than = condition.greater_than | |
| 85 | 86 | greater_than_or_equal_to = condition.greater_than_or_equal_to | |
| 86 | 87 | in_list = condition.in_list | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -21,7 +21,7 @@ | |||
| 21 | 21 | import decimal | |
| 22 | 22 | import enum | |
| 23 | 23 | 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 | ||
| 25 | 25 | ||
| 26 | 26 | from spanner_orm import error | |
| 27 | 27 | from spanner_orm import field | |
@@ -364,10 +364,10 @@ def _validate(self, model_class: Type[Any]) -> None: | |||
| 364 | 364 | origin.name, dest.name)) | |
| 365 | 365 | ||
| 366 | 366 | ||
| 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.""" | ||
| 369 | 369 | ||
| 370 | - def __init__(self, index_or_name: Union[Type[index.Index], str]): | ||
| 370 | + def __init__(self, index_or_name: Union[index.Index, str]): | ||
| 371 | 371 | super().__init__() | |
| 372 | 372 | if isinstance(index_or_name, index.Index): | |
| 373 | 373 | self.name = index_or_name.name | |
@@ -380,30 +380,63 @@ def bind(self, model_class: Type[Any]) -> None: | |||
| 380 | 380 | super().bind(model_class) | |
| 381 | 381 | self.index = self.model_class.indexes[self.name] | |
| 382 | 382 | ||
| 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 | + | ||
| 383 | 404 | def _params(self) -> Dict[str, Any]: | |
| 384 | 405 | return {} | |
| 385 | 406 | ||
| 386 | 407 | def segment(self) -> Segment: | |
| 387 | 408 | return Segment.FROM | |
| 388 | 409 | ||
| 389 | 410 | 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)}}}' | ||
| 391 | 413 | ||
| 392 | 414 | def _types(self) -> Dict[str, type_pb2.Type]: | |
| 393 | 415 | return {} | |
| 394 | 416 | ||
| 395 | 417 | 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) | ||
| 403 | 419 | if model_class.indexes[self.name].primary: | |
| 404 | 420 | raise error.ValidationError('Cannot force query using primary index') | |
| 405 | 421 | ||
| 406 | 422 | ||
| 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 | + | ||
| 407 | 440 | class IncludesCondition(Condition): | |
| 408 | 441 | """Used to include related model_classs via a relation in a Spanner query.""" | |
| 409 | 442 | ||
@@ -888,6 +921,35 @@ def force_index(forced_index: Union[index.Index, str]) -> ForceIndexCondition: | |||
| 888 | 921 | return ForceIndexCondition(forced_index) | |
| 889 | 922 | ||
| 890 | 923 | ||
| 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 | + | ||
| 891 | 953 | def greater_than(column: Union[field.Field, str], | |
| 892 | 954 | value: Any) -> ComparisonCondition: | |
| 893 | 955 | """Condition where the specified column is greater than the given value. | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -333,6 +333,16 @@ def test_contains( | |||
| 333 | 333 | )), | |
| 334 | 334 | ) | |
| 335 | 335 | ||
| 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 | + | ||
| 336 | 346 | ||
| 337 | 347 | if __name__ == '__main__': | |
| 338 | 348 | logging.basicConfig() | |
| Original file line number | Diff line number | Diff 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__) | ||
| Original file line number | Diff line number | Diff 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 | + ) | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -130,3 +130,13 @@ class UnittestModelWithoutSecondaryIndexes(model.Model): | |||
| 130 | 130 | bytes_2 = field.Field(field.BytesBase64, nullable=True) | |
| 131 | 131 | timestamp = field.Field(field.Timestamp) | |
| 132 | 132 | 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) | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments