Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ __pycache__
.eggs
*.egg-info
.pytype
env/

# Files that may or may not be added to the repo while acquiring the Spanner
# emulator.
Expand Down
4 changes: 3 additions & 1 deletion spanner_orm/admin/column.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from spanner_orm import field
from spanner_orm.admin import schema

allow_commit_timestamp_option = ' OPTIONS (allow_commit_timestamp=true)'


class ColumnSchema(schema.InformationSchema):
"""Model for interacting with Spanner column schema table."""
Expand All @@ -39,7 +41,7 @@ def nullable(self) -> bool:

def field_type(self) -> Type[field.FieldType]:
for field_type in field.ALL_TYPES:
if self.spanner_type == field_type.ddl():
if field_type.matches(self.spanner_type):
return field_type

raise error.SpannerError('No corresponding Type for {}'.format(
Expand Down
166 changes: 162 additions & 4 deletions spanner_orm/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,58 @@ def grpc_type() -> spanner_v1.Type:
def validate_type(value: Any) -> None:
raise NotImplementedError

@staticmethod
def matches(type: str) -> bool:
raise NotImplementedError

@staticmethod
def support_length() -> bool:
raise NotImplementedError

@staticmethod
def support_commit_timestamp() -> bool:
raise NotImplementedError


class Field(object):
"""Represents a column in a table as a field in a model."""

def __init__(self,
field_type: Type[FieldType],
nullable: bool = False,
primary_key: bool = False):
primary_key: bool = False,
length: int = 0,
allow_commit_timestamp: bool = False):
self.name = None
self._type = field_type
self._nullable = nullable
self._primary_key = primary_key
self._length = length
self._allow_commit_timestamp = allow_commit_timestamp

if self._length < 0:
raise error.ValidationError('length can not be less than zero')

if not self._type.support_length() and self._length:
raise error.ValidationError('length can not be set on field {}'.format(
self._type))

if not self._type.support_commit_timestamp(
) and self._allow_commit_timestamp:
raise error.ValidationError(
'allow_commit_timestamp can not be set on field {}'.format(
self._type))

def ddl(self) -> str:
base_ddl = self._type.ddl()
options = ''
if self._length:
base_ddl = base_ddl.replace('(MAX)', f'({self._length})')
if self._allow_commit_timestamp:
options = ' OPTIONS (allow_commit_timestamp=true)'
if self._nullable:
return self._type.ddl()
return '{field_type} NOT NULL'.format(field_type=self._type.ddl())
return f'{base_ddl}{options}'
return f'{base_ddl} NOT NULL{options}'

def field_type(self) -> Type[FieldType]:
return self._type
Expand Down Expand Up @@ -97,6 +132,18 @@ def validate_type(value: Any) -> None:
if not isinstance(value, bool):
raise error.ValidationError('{} is not of type bool'.format(value))

@staticmethod
def matches(type: str) -> bool:
return type == 'BOOL'

@staticmethod
def support_length() -> bool:
return False

@staticmethod
def support_commit_timestamp() -> bool:
return False


class Integer(FieldType):
"""Represents an integer type."""
Expand All @@ -114,6 +161,18 @@ def validate_type(value: Any) -> None:
if not isinstance(value, int):
raise error.ValidationError('{} is not of type int'.format(value))

@staticmethod
def matches(type: str) -> bool:
return type == 'INT64'

@staticmethod
def support_length() -> bool:
return False

@staticmethod
def support_commit_timestamp() -> bool:
return False


class Float(FieldType):
"""Represents a float type."""
Expand All @@ -131,6 +190,18 @@ def validate_type(value: Any) -> None:
if not isinstance(value, (int, float)):
raise error.ValidationError('{} is not of type float'.format(value))

@staticmethod
def matches(type: str) -> bool:
return type == 'FLOAT64'

@staticmethod
def support_length() -> bool:
return False

@staticmethod
def support_commit_timestamp() -> bool:
return False


class String(FieldType):
"""Represents a string type."""
Expand All @@ -148,6 +219,18 @@ def validate_type(value) -> None:
if not isinstance(value, str):
raise error.ValidationError('{} is not of type str'.format(value))

@staticmethod
def matches(type: str) -> bool:
return type[0:7] == 'STRING(' and type[-1] == ')'

@staticmethod
def support_length() -> bool:
return True

@staticmethod
def support_commit_timestamp() -> bool:
return False


class StringArray(FieldType):
"""Represents an array of strings type."""
Expand All @@ -168,6 +251,50 @@ def validate_type(value: Any) -> None:
if not isinstance(item, str):
raise error.ValidationError('{} is not of type str'.format(item))

@staticmethod
def matches(type: str) -> bool:
return type[0:13] == 'ARRAY<STRING(' and type[-2:] == ')>'

@staticmethod
def support_length() -> bool:
return True

@staticmethod
def support_commit_timestamp() -> bool:
return False


class IntArray(FieldType):
"""Represents an array of strings type."""

@staticmethod
def ddl() -> str:
return 'ARRAY<INT64>'

@staticmethod
def grpc_type() -> spanner_v1.Type:
return spanner.param_types.Array(spanner.param_types.INT64)

@staticmethod
def validate_type(value: Any) -> None:
if not isinstance(value, list):
raise error.ValidationError('{} is not of type list'.format(value))
for item in value:
if not isinstance(item, int):
raise error.ValidationError('{} is not of type int'.format(item))

@staticmethod
def matches(type: str) -> bool:
return type == 'ARRAY<INT64>'

@staticmethod
def support_length() -> bool:
return False

@staticmethod
def support_commit_timestamp() -> bool:
return False


class Timestamp(FieldType):
"""Represents a timestamp type."""
Expand All @@ -185,6 +312,18 @@ def validate_type(value: Any) -> None:
if not isinstance(value, datetime.datetime):
raise error.ValidationError('{} is not of type datetime'.format(value))

@staticmethod
def matches(type: str) -> bool:
return type.startswith('TIMESTAMP')

@staticmethod
def support_length() -> bool:
return False

@staticmethod
def support_commit_timestamp() -> bool:
return True


class BytesBase64(FieldType):
"""Represents a bytes type that must be base64 encoded."""
Expand All @@ -208,7 +347,26 @@ def validate_type(value) -> None:
raise error.ValidationError(
'{} must be base64-encoded bytes.'.format(value))

@staticmethod
def matches(type: str) -> bool:
return type[0:6] == 'BYTES(' and type[-1] == ')'

@staticmethod
def support_length() -> bool:
return True

@staticmethod
def support_commit_timestamp() -> bool:
return False


ALL_TYPES = [
Boolean, Integer, Float, String, StringArray, Timestamp, BytesBase64
Boolean,
Integer,
IntArray,
Float,
String,
StringArray,
Timestamp,
BytesBase64,
]
1 change: 1 addition & 0 deletions spanner_orm/tests/migrations_emulator_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def test_key(self):
'int_': 42,
'float_': 4.2,
'bytes_': b'A1A1',
'int_array': [1, 2],
'timestamp': datetime.datetime.now(tz=datetime.timezone.utc),
}).save()
models.ForeignKeyTestModel({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Spanner ORM migration: create_timstamp_option.

Migration ID: '8c106e068a1a'
Created: 2022-09-12 19:38:24-07:00
"""

import spanner_orm

migration_id = '8c106e068a1a'
prev_migration_id = '69a8f072dacf'


class OriginalTeeTable(spanner_orm.model.Model):
"""ORM Model with the original schema for the Commands table.

Don't update this model, create new migrations instead.
"""

__table__ = 'Tee'
id = spanner_orm.Field(spanner_orm.String, primary_key=True)
timestamp = spanner_orm.Field(
spanner_orm.Timestamp, nullable=False, allow_commit_timestamp=True)
cus_str = spanner_orm.Field(spanner_orm.String, length=555)
cus_bytes = spanner_orm.Field(spanner_orm.BytesBase64, length=12)
cus_strarr = spanner_orm.Field(spanner_orm.StringArray, length=24)


def upgrade() -> spanner_orm.CreateTable:
"""Creates the original Commands table."""
return spanner_orm.CreateTable(OriginalTeeTable)


def downgrade() -> spanner_orm.DropTable:
"""Drops the original Commands table."""
return spanner_orm.DropTable(OriginalTeeTable.__table__)
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class OriginalUnittestModelTable(spanner_orm.model.Model):
bytes_2 = field.Field(field.BytesBase64, nullable=True)
timestamp = field.Field(field.Timestamp)
string_array = field.Field(field.StringArray, nullable=True)
int_array = field.Field(field.IntArray, nullable=True)


def upgrade() -> spanner_orm.CreateTable:
Expand Down
24 changes: 22 additions & 2 deletions spanner_orm/tests/model_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,8 @@ def test_set_error_on_primary_key(self):

@parameterized.parameters(('int_2', 'foo'), ('float_2', 'bar'),
('string_2', 5), ('bytes_2', 'string'),
('string_array', 'foo'), ('timestamp', 5))
('string_array', 'foo'), ('timestamp', 5),
('int_array', 12))
def test_set_error_on_invalid_type(self, attribute, value):
string_array = ['foo', 'bar']
timestamp = datetime.datetime.now(tz=datetime.timezone.utc)
Expand Down Expand Up @@ -321,6 +322,22 @@ def test_model_equates(self):
'string_array': ['bar', 'foo'],
'timestamp': _TIMESTAMP,
})),
(models.UnittestModel({
'int_': 0,
'float_': 0,
'string': '',
'bytes_': b'A1A1',
'int_array': [1, 2],
'timestamp': _TIMESTAMP,
}),
models.UnittestModel({
'int_': 0,
'float_': 0,
'string': '',
'bytes_': b'A1A1',
'int_array': [2, 1],
'timestamp': _TIMESTAMP,
})),
(models.SmallTestModel({
'key': 'key',
'value_1': 'value'
Expand All @@ -342,7 +359,8 @@ def test_id(self):
all_data = primary_key.copy()
all_data.update({
'timestamp': datetime.datetime.now(tz=datetime.timezone.utc),
'string_array': ['foo', 'bar']
'string_array': ['foo', 'bar'],
'int_array': [1, 2],
})
test_model = models.UnittestModel(all_data)
self.assertEqual(test_model.id(), primary_key)
Expand All @@ -357,13 +375,15 @@ def test_changes(self):

def test_object_changes(self):
array = ['foo', 'bar']
int_array = [1, 2]
timestamp = datetime.datetime.now(tz=datetime.timezone.utc)
test_model = models.UnittestModel({
'int_': 0,
'float_': 0,
'string': '',
'bytes_': b'',
'string_array': array,
'int_array': int_array,
'timestamp': timestamp
})

Expand Down
7 changes: 7 additions & 0 deletions spanner_orm/tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ class UnittestModel(model.Model):
bytes_2 = field.Field(field.BytesBase64, nullable=True)
timestamp = field.Field(field.Timestamp)
string_array = field.Field(field.StringArray, nullable=True)
int_array = field.Field(field.IntArray, nullable=True)

test_index = index.Index(['string_2'])

Expand All @@ -126,10 +127,16 @@ class UnittestModelWithoutSecondaryIndexes(model.Model):
float_2 = field.Field(field.Float, nullable=True)
string = field.Field(field.String, primary_key=True)
string_2 = field.Field(field.String, nullable=True)
string_3 = field.Field(field.String, nullable=True, length=20)
bytes_ = field.Field(field.BytesBase64, primary_key=True)
bytes_2 = field.Field(field.BytesBase64, nullable=True)
bytes_3 = field.Field(field.BytesBase64, nullable=True, length=20)
timestamp = field.Field(field.Timestamp)
timestamp_2 = field.Field(
field.Timestamp, nullable=True, allow_commit_timestamp=True)
string_array = field.Field(field.StringArray, nullable=True)
string_array_2 = field.Field(field.StringArray, nullable=True, length=20)
int_array = field.Field(field.IntArray, nullable=True)


class NullFilteredIndexModel(model.Model):
Expand Down
Loading