Skip to content

Commit 74d1609

Browse files
Random model builder (piccolo-orm#169)
* add random model builders * add `build` and `build_sync` methods * Use `run_sync` * Make `build` classmethods, use Piccolo for column checking and update docs * updated docs Made sure `await` is used where appropriate, and added link to the new tester app Co-authored-by: Daniel Townsend <[email protected]>
1 parent 9bddedb commit 74d1609

File tree

9 files changed

+475
-0
lines changed

9 files changed

+475
-0
lines changed

docs/src/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Welcome to Piccolo's documentation!
1414
piccolo/migrations/index
1515
piccolo/authentication/index
1616
piccolo/asgi/index
17+
piccolo/testing/index
1718
piccolo/features/index
1819
piccolo/playground/index
1920
piccolo/deployment/index

docs/src/piccolo/projects_and_apps/included_apps.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ need to run raw SQL queries on your database.
101101
For it to work, the underlying command needs to be on the path (i.e. ``psql``
102102
or ``sqlite3`` depending on which you're using).
103103

104+
.. _TesterApp:
105+
104106
tester
105107
~~~~~~
106108

docs/src/piccolo/testing/index.rst

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
Testing
2+
=======
3+
4+
Piccolo provides a few tools to make testing easier and decrease manual work.
5+
6+
Model Builder
7+
-------------
8+
9+
When writing unit tests, it's usually required to have some data seeded into the database.
10+
You can build and save the records manually or use ``ModelBuilder`` to generate random records for you.
11+
12+
This way you can randomize the fields you don't care about and specify important fields explicitly and
13+
reduce the amount of manual work required.
14+
``ModelBuilder`` currently supports all Piccolo column types and features.
15+
16+
Let's say we have the following schema:
17+
18+
.. code-block:: python
19+
20+
from piccolo.columns import ForeignKey, Varchar
21+
22+
class Manager(Table):
23+
name = Varchar(length=50)
24+
25+
class Band(Table):
26+
name = Varchar(length=50)
27+
manager = ForeignKey(Manager, null=True)
28+
29+
You can build a random ``Band`` which will also build and save a random ``Manager``:
30+
31+
.. code-block:: python
32+
33+
from piccolo.testing.model_builder import ModelBuilder
34+
35+
band = await ModelBuilder.build(Band) # Band instance with random values persisted
36+
37+
.. note:: ``ModelBuilder.build(Band)`` persists record into the database by default.
38+
39+
You can also run it synchronously if you prefer:
40+
41+
.. code-block:: python
42+
43+
manager = ModelBuilder.build_sync(Manager)
44+
45+
46+
To specify any attribute, pass the ``defaults`` dictionary to the ``build`` method:
47+
48+
.. code-block:: python
49+
50+
manager = ModelBuilder.build(Manager)
51+
52+
# Using table columns
53+
band = await ModelBuilder.build(Band, defaults={Band.name: "Guido", Band.manager: manager})
54+
55+
# Or using strings as keys
56+
band = await ModelBuilder.build(Band, defaults={"name": "Guido", "manager": manager})
57+
58+
To build objects without persisting them into the database:
59+
60+
.. code-block:: python
61+
62+
band = await ModelBuilder.build(Band, persist=False)
63+
64+
To build object with minimal attributes, leaving nullable fields empty:
65+
66+
.. code-block:: python
67+
68+
band = await ModelBuilder.build(Band, minimal=True) # Leaves manager empty
69+
70+
Test runner
71+
-----------
72+
73+
See the :ref:`tester app<TesterApp>`.

piccolo/testing/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from piccolo.testing.model_builder import ModelBuilder
2+
3+
__all__ = ["ModelBuilder"]

piccolo/testing/model_builder.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import json
2+
import typing as t
3+
from datetime import date, datetime, time, timedelta
4+
from decimal import Decimal
5+
from uuid import UUID
6+
7+
from piccolo.columns.base import Column
8+
from piccolo.table import Table
9+
from piccolo.testing.random_builder import RandomBuilder
10+
from piccolo.utils.sync import run_sync
11+
12+
13+
class ModelBuilder:
14+
__DEFAULT_MAPPER: t.Dict[t.Type, t.Callable] = {
15+
bool: RandomBuilder.next_bool,
16+
bytes: RandomBuilder.next_bytes,
17+
date: RandomBuilder.next_date,
18+
datetime: RandomBuilder.next_datetime,
19+
float: RandomBuilder.next_float,
20+
int: RandomBuilder.next_int,
21+
str: RandomBuilder.next_str,
22+
time: RandomBuilder.next_time,
23+
timedelta: RandomBuilder.next_timedelta,
24+
UUID: RandomBuilder.next_uuid,
25+
}
26+
27+
@classmethod
28+
async def build(
29+
cls,
30+
table_class: t.Type[Table],
31+
defaults: t.Dict[t.Union[Column, str], t.Any] = None,
32+
persist: bool = True,
33+
minimal: bool = False,
34+
) -> Table:
35+
"""
36+
Build Table instance with random data and save async.
37+
This can build relationships, supported data types and parameters.
38+
39+
:param table_class:
40+
Table class to randomize.
41+
42+
Examples:
43+
44+
manager = ModelBuilder.build(Manager)
45+
manager = ModelBuilder.build(Manager, name='Guido')
46+
manager = ModelBuilder(persist=False).build(Manager)
47+
manager = ModelBuilder(minimal=True).build(Manager)
48+
band = ModelBuilder.build(Band, manager=manager)
49+
"""
50+
return await cls._build(
51+
table_class=table_class,
52+
defaults=defaults,
53+
persist=persist,
54+
minimal=minimal,
55+
)
56+
57+
@classmethod
58+
def build_sync(
59+
cls,
60+
table_class: t.Type[Table],
61+
defaults: t.Dict[t.Union[Column, str], t.Any] = None,
62+
persist: bool = True,
63+
minimal: bool = False,
64+
) -> Table:
65+
"""
66+
Build Table instance with random data and save sync.
67+
This can build relationships, supported data types and parameters.
68+
69+
:param table_class:
70+
Table class to randomize.
71+
72+
Examples:
73+
74+
manager = ModelBuilder.build_sync(Manager)
75+
manager = ModelBuilder.build_sync(Manager, name='Guido')
76+
manager = ModelBuilder(persist=False).build_sync(Manager)
77+
manager = ModelBuilder(minimal=True).build_sync(Manager)
78+
band = ModelBuilder.build_sync(Band, manager=manager)
79+
"""
80+
return run_sync(
81+
cls.build(
82+
table_class=table_class,
83+
defaults=defaults,
84+
persist=persist,
85+
minimal=minimal,
86+
)
87+
)
88+
89+
@classmethod
90+
async def _build(
91+
cls,
92+
table_class: t.Type[Table],
93+
defaults: t.Dict[t.Union[Column, str], t.Any] = None,
94+
minimal: bool = False,
95+
persist: bool = True,
96+
) -> Table:
97+
model = table_class()
98+
defaults = {} if not defaults else defaults
99+
100+
for column, value in defaults.items():
101+
if isinstance(column, str):
102+
column = model._meta.get_column_by_name(column)
103+
104+
setattr(model, column._meta.name, value)
105+
106+
for column in model._meta.columns:
107+
108+
if column._meta.null and minimal:
109+
continue
110+
111+
if column._meta.name in defaults:
112+
continue # Column value exists
113+
114+
if "references" in column._meta.params and persist:
115+
reference_model = await cls._build(
116+
column._meta.params["references"],
117+
persist=True,
118+
)
119+
random_value = getattr(
120+
reference_model,
121+
reference_model._meta.primary_key._meta.name,
122+
)
123+
else:
124+
random_value = cls._randomize_attribute(column)
125+
126+
setattr(model, column._meta.name, random_value)
127+
128+
if persist:
129+
await model.save().run()
130+
131+
return model
132+
133+
@classmethod
134+
def _randomize_attribute(cls, column: Column) -> t.Any:
135+
"""
136+
Generate a random value for a column and apply formattings.
137+
138+
:param column:
139+
Column class to randomize.
140+
"""
141+
if column.value_type == Decimal:
142+
precision, scale = column._meta.params["digits"]
143+
random_value = RandomBuilder.next_float(
144+
maximum=10 ** (precision - scale), scale=scale
145+
)
146+
elif column._meta.choices:
147+
random_value = RandomBuilder.next_enum(column._meta.choices)
148+
else:
149+
random_value = cls.__DEFAULT_MAPPER[column.value_type]()
150+
151+
if "length" in column._meta.params and isinstance(random_value, str):
152+
return random_value[: column._meta.params["length"]]
153+
elif column.column_type in ["JSON", "JSONB"]:
154+
return json.dumps(random_value)
155+
156+
return random_value

piccolo/testing/random_builder.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import enum
2+
import random
3+
import string
4+
import typing as t
5+
import uuid
6+
from datetime import date, datetime, time, timedelta
7+
8+
9+
class RandomBuilder:
10+
@classmethod
11+
def next_bool(cls) -> bool:
12+
return random.choice([True, False])
13+
14+
@classmethod
15+
def next_bytes(cls, length=8) -> bytes:
16+
return random.getrandbits(length * 8).to_bytes(length, "little")
17+
18+
@classmethod
19+
def next_date(cls) -> date:
20+
return date(
21+
year=random.randint(2000, 2050),
22+
month=random.randint(1, 12),
23+
day=random.randint(1, 28),
24+
)
25+
26+
@classmethod
27+
def next_datetime(cls) -> datetime:
28+
return datetime(
29+
year=random.randint(2000, 2050),
30+
month=random.randint(1, 12),
31+
day=random.randint(1, 28),
32+
hour=random.randint(0, 23),
33+
minute=random.randint(0, 59),
34+
second=random.randint(0, 59),
35+
)
36+
37+
@classmethod
38+
def next_enum(cls, e: t.Type[enum.Enum]) -> t.Any:
39+
return random.choice([item.value for item in e])
40+
41+
@classmethod
42+
def next_float(cls, minimum=0, maximum=2147483647, scale=5) -> float:
43+
return round(random.uniform(minimum, maximum), scale)
44+
45+
@classmethod
46+
def next_int(cls, minimum=0, maximum=2147483647) -> int:
47+
return random.randint(minimum, maximum)
48+
49+
@classmethod
50+
def next_str(cls, length=16) -> str:
51+
return "".join(
52+
random.choice(string.ascii_letters) for _ in range(length)
53+
)
54+
55+
@classmethod
56+
def next_time(cls) -> time:
57+
return time(
58+
hour=random.randint(0, 23),
59+
minute=random.randint(0, 59),
60+
second=random.randint(0, 59),
61+
)
62+
63+
@classmethod
64+
def next_timedelta(cls) -> timedelta:
65+
return timedelta(
66+
days=random.randint(1, 7),
67+
hours=random.randint(1, 23),
68+
minutes=random.randint(0, 59),
69+
)
70+
71+
@classmethod
72+
def next_uuid(cls) -> uuid.UUID:
73+
return uuid.uuid4()

tests/testing/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)