Skip to content

Commit c17732e

Browse files
authored
Merge pull request #7 from python-ellar/type_decorator_test
Added tests for SQLAlchemy type decorators
2 parents 4ec7d90 + 46f1de0 commit c17732e

File tree

11 files changed

+477
-27
lines changed

11 files changed

+477
-27
lines changed

ellar_sqlalchemy/model/typeDecorator/exceptions.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import typing as t
22

33

4-
class ContentTypeValidationError(Exception):
4+
class ContentTypeValidationError(ValueError):
55
def __init__(
66
self,
77
content_type: t.Optional[str] = None,
@@ -18,14 +18,14 @@ def __init__(
1818
super().__init__(message)
1919

2020

21-
class InvalidFileError(Exception):
21+
class InvalidFileError(ValueError):
2222
pass
2323

2424

25-
class InvalidImageOperationError(Exception):
25+
class InvalidImageOperationError(ValueError):
2626
pass
2727

2828

29-
class MaximumAllowedFileLengthError(Exception):
29+
class MaximumAllowedFileLengthError(ValueError):
3030
def __init__(self, max_length: int) -> None:
3131
super().__init__("Cannot store files larger than: %d bytes" % max_length)

ellar_sqlalchemy/model/typeDecorator/file/base.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import time
33
import typing as t
44
import uuid
5+
from abc import abstractmethod
56

67
import sqlalchemy as sa
8+
from ellar.common import UploadFile
79
from ellar.core.files.storages import BaseStorage
810
from ellar.core.files.storages.utils import get_valid_filename
9-
from starlette.datastructures import UploadFile
1011

1112
from ellar_sqlalchemy.model.typeDecorator.exceptions import (
1213
ContentTypeValidationError,
@@ -25,7 +26,10 @@
2526

2627

2728
class FileFieldBase(t.Generic[T]):
28-
FileObject: t.Type[T] = t.cast(t.Type[T], FileObject)
29+
@property
30+
@abstractmethod
31+
def file_object_type(self) -> t.Type[T]:
32+
...
2933

3034
def load_dialect_impl(self, dialect: sa.Dialect) -> t.Any:
3135
if dialect.name == "sqlite":
@@ -74,7 +78,7 @@ def load_from_str(self, data: str) -> T:
7478
def load(self, data: t.Dict[str, t.Any]) -> T:
7579
if "service_name" in data:
7680
data.pop("service_name")
77-
return self.FileObject(storage=self.storage, **data)
81+
return self.file_object_type(storage=self.storage, **data)
7882

7983
def _guess_content_type(self, file: t.IO) -> str: # type:ignore[type-arg]
8084
content = file.read(1024)
@@ -97,14 +101,15 @@ def convert_to_file_object(self, file: UploadFile) -> T:
97101
original_filename = file.filename or unique_name
98102

99103
# use python magic to get the content type
100-
content_type = self._guess_content_type(file.file)
104+
content_type = self._guess_content_type(file.file) or ""
101105
extension = guess_extension(content_type)
102-
assert extension
103-
104106
file_size = get_length(file.file)
105-
saved_filename = (
106-
f"{original_filename[:-len(extension)]}_{unique_name[:-8]}{extension}"
107-
)
107+
if extension:
108+
saved_filename = (
109+
f"{original_filename[:-len(extension)]}_{unique_name[:-8]}{extension}"
110+
)
111+
else:
112+
saved_filename = f"{unique_name[:-8]}_{original_filename}"
108113
saved_filename = get_valid_filename(saved_filename)
109114

110115
init_kwargs = self.get_extra_file_initialization_context(file)
@@ -117,7 +122,7 @@ def convert_to_file_object(self, file: UploadFile) -> T:
117122
file_size=file_size,
118123
saved_filename=saved_filename,
119124
)
120-
return self.FileObject(**init_kwargs)
125+
return self.file_object_type(**init_kwargs)
121126

122127
def process_bind_param_action(
123128
self, value: t.Optional[t.Any], dialect: sa.Dialect
@@ -138,7 +143,7 @@ def process_bind_param_action(
138143
return json.dumps(value.to_dict())
139144
return value.to_dict()
140145

141-
raise InvalidFileError()
146+
raise InvalidFileError(f"{value} is not supported")
142147

143148
def process_result_value_action(
144149
self, value: t.Optional[t.Any], dialect: sa.Dialect

ellar_sqlalchemy/model/typeDecorator/file/field.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88

99
class FileField(FileFieldBase[FileObject], sa.TypeDecorator): # type: ignore[type-arg]
10+
1011
"""
1112
Provide SqlAlchemy TypeDecorator for saving files
1213
## Basic Usage
@@ -28,6 +29,10 @@ def route(file: File[UploadFile]):
2829
2930
"""
3031

32+
@property
33+
def file_object_type(self) -> t.Type[FileObject]:
34+
return FileObject
35+
3136
impl = sa.JSON
3237

3338
def process_bind_param(

ellar_sqlalchemy/model/typeDecorator/file/file_info.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,5 @@ def to_dict(self) -> t.Dict[str, t.Any]:
4242
"service_name": self._storage.service_name(),
4343
}
4444

45-
def __str__(self) -> str:
46-
return f"filename={self.filename}, content_type={self.content_type}, file_size={self.file_size}"
47-
4845
def __repr__(self) -> str:
49-
return str(self)
46+
return f"<{self.__class__.__name__} filename={self.filename}, content_type={self.content_type}, file_size={self.file_size}>"

ellar_sqlalchemy/model/typeDecorator/image/field.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
from io import SEEK_END, BytesIO
33

44
import sqlalchemy as sa
5+
from ellar.common import UploadFile
56
from ellar.core.files.storages import BaseStorage
67
from PIL import Image
7-
from starlette.datastructures import UploadFile
88

99
from ..exceptions import InvalidImageOperationError
1010
from ..file import FileFieldBase
@@ -18,9 +18,10 @@ class ImageFileField(FileFieldBase[ImageFileObject], sa.TypeDecorator): # type:
1818
## Basic Usage
1919
2020
class MyTable(Base):
21-
image:
22-
ImageFileField.FileObject = sa.Column(ImageFileField(storage=FileSystemStorage('path/to/save/files',
23-
max_size=10*MB), nullable=True)
21+
image: ImageFileField.FileObject = sa.Column(
22+
ImageFileField(storage=FileSystemStorage('path/to/save/files', max_size=10*MB),
23+
nullable=True
24+
)
2425
2526
def route(file: File[UploadFile]):
2627
session = SessionLocal()
@@ -46,7 +47,6 @@ def route(file: File[UploadFile]):
4647
"""
4748

4849
impl = sa.JSON
49-
FileObject = ImageFileObject
5050

5151
def __init__(
5252
self,
@@ -60,6 +60,10 @@ def __init__(
6060
super().__init__(*args, storage=storage, max_size=max_size, **kwargs)
6161
self.crop = crop
6262

63+
@property
64+
def file_object_type(self) -> t.Type[ImageFileObject]:
65+
return ImageFileObject
66+
6367
def process_bind_param(
6468
self, value: t.Optional[t.Any], dialect: sa.Dialect
6569
) -> t.Any:
@@ -73,9 +77,12 @@ def process_result_value(
7377
def get_extra_file_initialization_context(
7478
self, file: UploadFile
7579
) -> t.Dict[str, t.Any]:
76-
with Image.open(file.file) as image:
77-
width, height = image.size
78-
return {"width": width, "height": height}
80+
try:
81+
with Image.open(file.file) as image:
82+
width, height = image.size
83+
return {"width": width, "height": height}
84+
except Exception:
85+
return {"width": None, "height": None}
7986

8087
def crop_image_with_box_sizing(
8188
self, file: UploadFile, crop: t.Optional[CroppingDetails] = None

tests/test_type_decorators/__init__.py

Whitespace-only changes.
1.52 MB
Loading
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import os
2+
import uuid
3+
from io import BytesIO
4+
from unittest.mock import patch
5+
6+
import pytest
7+
import sqlalchemy.exc as sa_exc
8+
from ellar.common.datastructures import ContentFile, UploadFile
9+
from ellar.core.files import storages
10+
from starlette.datastructures import Headers
11+
12+
from ellar_sqlalchemy import model
13+
from ellar_sqlalchemy.model.utils import MB
14+
15+
16+
def serialize_file_data(file):
17+
keys = {
18+
"original_filename",
19+
"content_type",
20+
"extension",
21+
"file_size",
22+
"service_name",
23+
}
24+
return {k: v for k, v in file.to_dict().items() if k in keys}
25+
26+
27+
def test_file_column_type(db_service, ignore_base, tmp_path):
28+
path = str(tmp_path / "files")
29+
fs = storages.FileSystemStorage(path)
30+
31+
class File(model.Model):
32+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
33+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
34+
)
35+
file: model.Mapped[model.FileObject] = model.mapped_column(
36+
"file", model.FileField(storage=fs), nullable=False
37+
)
38+
39+
db_service.create_all()
40+
session = db_service.session_factory()
41+
session.add(File(file=ContentFile(b"Testing file column type", name="text.txt")))
42+
session.commit()
43+
44+
file: File = session.execute(model.select(File)).scalar()
45+
assert "content_type=text/plain" in repr(file.file)
46+
47+
data = serialize_file_data(file.file)
48+
assert data == {
49+
"content_type": "text/plain",
50+
"extension": ".txt",
51+
"file_size": 24,
52+
"original_filename": "text.txt",
53+
"service_name": "local",
54+
}
55+
56+
assert os.listdir(path)[0].split(".")[1] == "txt"
57+
58+
59+
def test_file_column_invalid_file_extension(db_service, ignore_base, tmp_path):
60+
fs = storages.FileSystemStorage(str(tmp_path / "files"))
61+
62+
class File(model.Model):
63+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
64+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
65+
)
66+
file: model.Mapped[model.FileObject] = model.mapped_column(
67+
"file",
68+
model.FileField(storage=fs, allowed_content_types=["application/pdf"]),
69+
nullable=False,
70+
)
71+
72+
with pytest.raises(sa_exc.StatementError) as stmt_exc:
73+
db_service.create_all()
74+
session = db_service.session_factory()
75+
session.add(
76+
File(file=ContentFile(b"Testing file column type", name="text.txt"))
77+
)
78+
session.commit()
79+
assert (
80+
str(stmt_exc.value.orig)
81+
== "Content type is not supported text/plain. Valid options are: application/pdf"
82+
)
83+
84+
85+
@patch(
86+
"ellar_sqlalchemy.model.typeDecorator.file.base.magic_mime_from_buffer",
87+
return_value=None,
88+
)
89+
def test_file_column_invalid_file_extension_case_2(
90+
mock_buffer, db_service, ignore_base, tmp_path
91+
):
92+
fs = storages.FileSystemStorage(str(tmp_path / "files"))
93+
94+
class File(model.Model):
95+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
96+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
97+
)
98+
file: model.Mapped[model.FileObject] = model.mapped_column(
99+
"file",
100+
model.FileField(storage=fs, allowed_content_types=["application/pdf"]),
101+
nullable=False,
102+
)
103+
104+
with pytest.raises(sa_exc.StatementError) as stmt_exc:
105+
db_service.create_all()
106+
session = db_service.session_factory()
107+
session.add(
108+
File(
109+
file=UploadFile(
110+
BytesIO(b"Testing file column type"),
111+
size=24,
112+
filename="test.txt",
113+
headers=Headers({"content-type": ""}),
114+
)
115+
)
116+
)
117+
session.commit()
118+
assert mock_buffer.called
119+
assert (
120+
str(stmt_exc.value.orig)
121+
== "Content type is not supported . Valid options are: application/pdf"
122+
)
123+
124+
125+
@patch("ellar_sqlalchemy.model.typeDecorator.file.base.get_length", return_value=MB * 7)
126+
def test_file_column_invalid_file_size_case_2(
127+
mock_buffer, db_service, ignore_base, tmp_path
128+
):
129+
fs = storages.FileSystemStorage(str(tmp_path / "files"))
130+
131+
class File(model.Model):
132+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
133+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
134+
)
135+
file: model.Mapped[model.FileObject] = model.mapped_column(
136+
"file", model.FileField(storage=fs, max_size=MB * 6), nullable=False
137+
)
138+
139+
with pytest.raises(sa_exc.StatementError) as stmt_exc:
140+
db_service.create_all()
141+
session = db_service.session_factory()
142+
session.add(File(file=ContentFile(b"Testing File Size Validation")))
143+
session.commit()
144+
assert mock_buffer.called
145+
assert str(stmt_exc.value.orig) == "Cannot store files larger than: 6291456 bytes"
146+
147+
148+
def test_file_column_invalid_set(db_service, ignore_base, tmp_path):
149+
fs = storages.FileSystemStorage(str(tmp_path / "files"))
150+
151+
class File(model.Model):
152+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
153+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
154+
)
155+
file: model.Mapped[model.FileObject] = model.mapped_column(
156+
"file", model.FileField(storage=fs, max_size=MB * 6), nullable=False
157+
)
158+
159+
db_service.create_all()
160+
session = db_service.session_factory()
161+
with pytest.raises(sa_exc.StatementError) as stmt_exc:
162+
session.add(File(file={}))
163+
session.commit()
164+
165+
assert str(stmt_exc.value.orig) == "{} is not supported"
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import uuid
2+
3+
from ellar_sqlalchemy import model
4+
5+
6+
def test_guid_column_type(db_service, ignore_base):
7+
uid = uuid.uuid4()
8+
9+
class Guid(model.Model):
10+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
11+
"id",
12+
model.GUID(),
13+
nullable=False,
14+
unique=True,
15+
primary_key=True,
16+
default=uuid.uuid4,
17+
)
18+
19+
db_service.create_all()
20+
session = db_service.session_factory()
21+
session.add(Guid(id=uid))
22+
session.commit()
23+
24+
guid = session.execute(model.select(Guid)).scalar()
25+
assert guid.id == uid

0 commit comments

Comments
 (0)