Skip to content

Commit 48768ba

Browse files
authored
Fixtures app (piccolo-orm#229)
* prototype for fixture dumping * add pretty option to dump_json * add fixture loading * added docs * improved docs - mentioned how multiple apps / tables can be specified * renamed app (fixtures -> fixture) * moved `MegaTable` into it's own file for reuse * refactored test file, to support multiple example apps * fix typo * added `digits` arg to `MegaTable.numeric_col` * use pydantic to serialise / deserialise json * tweaked docs * fix test * conditional asyncpg import * add missing docstrings * make TestDumpLoad work with sqlite * added tests for `create_pydantic_model`
1 parent 104c1a5 commit 48768ba

File tree

79 files changed

+1228
-114
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+1228
-114
lines changed

docs/src/piccolo/projects_and_apps/included_apps.rst

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ Included Apps
44
Just as you can modularise your own code using :ref:`apps<PiccoloApps>`, Piccolo itself
55
ships with several builtin apps, which provide a lot of its functionality.
66

7+
-------------------------------------------------------------------------------
8+
79
Auto includes
810
-------------
911

10-
The following are registered with your :ref:`AppRegistry<AppRegistry>` automatically:
12+
The following are registered with your :ref:`AppRegistry<AppRegistry>` automatically.
13+
14+
.. hint:: To find out more about each of these commands you can use the
15+
``--help`` flag on the command line. For example ``piccolo app new --help``.
1116

1217
-------------------------------------------------------------------------------
1318

@@ -33,6 +38,47 @@ Lets you scaffold an ASGI web app. See :ref:`ASGICommand`.
3338
3439
-------------------------------------------------------------------------------
3540

41+
fixture
42+
~~~~~~~
43+
44+
Fixtures are used when you want to seed your database with essential data (for
45+
example, country names).
46+
47+
Once you have created a fixture, it can be used by your colleagues when setting
48+
up an application on their local machines, or when deploying to a new
49+
environment.
50+
51+
Databases such as Postgres have inbuilt ways of dumping and restoring data
52+
(via ``pg_dump`` and ``pg_restore``). Some reasons to use the fixtures app
53+
instead:
54+
55+
* When you want the data to be loadable in a range of database versions.
56+
* Fixtures are stored in JSON, which are a bit friendlier for source control.
57+
58+
To dump the data into a new fixture file:
59+
60+
.. code-block:: bash
61+
62+
piccolo fixtures dump > fixtures.json
63+
64+
By default, the fixture contains data from all apps and tables. You can specify
65+
a subset of apps and tables instead, for example:
66+
67+
.. code-block:: bash
68+
69+
piccolo fixture dump --apps=blog --tables=Post > fixtures.json
70+
71+
# Or for multiple apps / tables
72+
piccolo fixtures dump --apps=blog,shop --tables=Post,Product > fixtures.json
73+
74+
To load the fixture:
75+
76+
.. code-block:: bash
77+
78+
piccolo fixture load fixtures.json
79+
80+
-------------------------------------------------------------------------------
81+
3682
meta
3783
~~~~
3884

piccolo/apps/fixture/commands/__init__.py

Whitespace-only changes.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from __future__ import annotations
2+
3+
import typing as t
4+
5+
from piccolo.apps.fixture.commands.shared import (
6+
FixtureConfig,
7+
create_pydantic_fixture_model,
8+
)
9+
from piccolo.apps.migrations.auto.migration_manager import sort_table_classes
10+
from piccolo.conf.apps import Finder
11+
12+
13+
async def get_dump(
14+
fixture_configs: t.List[FixtureConfig],
15+
) -> t.Dict[str, t.Any]:
16+
"""
17+
Gets the data for each table specified and returns a data structure like:
18+
19+
.. code-block:: python
20+
21+
{
22+
'my_app_name': {
23+
'MyTableName': [
24+
{
25+
'id': 1,
26+
'my_column_name': 'foo'
27+
}
28+
]
29+
}
30+
}
31+
32+
"""
33+
finder = Finder()
34+
35+
output: t.Dict[str, t.Any] = {}
36+
37+
for fixture_config in fixture_configs:
38+
app_config = finder.get_app_config(app_name=fixture_config.app_name)
39+
table_classes = [
40+
i
41+
for i in app_config.table_classes
42+
if i.__name__ in fixture_config.table_class_names
43+
]
44+
sorted_table_classes = sort_table_classes(table_classes)
45+
46+
output[fixture_config.app_name] = {}
47+
48+
for table_class in sorted_table_classes:
49+
data = await table_class.select().run()
50+
output[fixture_config.app_name][table_class.__name__] = data
51+
52+
return output
53+
54+
55+
async def dump_to_json_string(
56+
fixture_configs: t.List[FixtureConfig],
57+
) -> str:
58+
"""
59+
Dumps all of the data for the given tables into a JSON string.
60+
"""
61+
dump = await get_dump(fixture_configs=fixture_configs)
62+
pydantic_model = create_pydantic_fixture_model(
63+
fixture_configs=fixture_configs
64+
)
65+
json_output = pydantic_model(**dump).json()
66+
return json_output
67+
68+
69+
def parse_args(apps: str, tables: str) -> t.List[FixtureConfig]:
70+
"""
71+
Works out which apps and tables the user is referring to.
72+
"""
73+
finder = Finder()
74+
app_names = []
75+
76+
if apps == "all":
77+
app_names = finder.get_sorted_app_names()
78+
elif "," in apps:
79+
app_names = apps.split(",")
80+
else:
81+
# Must be a single app name
82+
app_names.append(apps)
83+
84+
table_class_names: t.Optional[t.List[str]] = None
85+
86+
if tables != "all":
87+
if "," in tables:
88+
table_class_names = tables.split(",")
89+
else:
90+
# Must be a single table class name
91+
table_class_names = [tables]
92+
93+
output: t.List[FixtureConfig] = []
94+
95+
for app_name in app_names:
96+
app_config = finder.get_app_config(app_name=app_name)
97+
table_classes = app_config.table_classes
98+
99+
if table_class_names is None:
100+
fixture_configs = [i.__name__ for i in table_classes]
101+
else:
102+
fixture_configs = [
103+
i.__name__
104+
for i in table_classes
105+
if i.__name__ in table_class_names
106+
]
107+
output.append(
108+
FixtureConfig(
109+
app_name=app_name,
110+
table_class_names=fixture_configs,
111+
)
112+
)
113+
114+
return output
115+
116+
117+
async def dump(apps: str = "all", tables: str = "all"):
118+
"""
119+
Serialises the data from the given Piccolo apps / tables, and prints it
120+
out.
121+
122+
:param apps:
123+
For all apps, specify `all`. For specific apps, pass in a comma
124+
separated list e.g. `blog,profiles,billing`. For a single app, just
125+
pass in the name of that app, e.g. `blog`.
126+
:param tables:
127+
For all tables, specify `all`. For specific tables, pass in a comma
128+
separated list e.g. `Post,Tag`. For a single app, just
129+
pass in the name of that app, e.g. `Post`.
130+
131+
"""
132+
fixture_configs = parse_args(apps=apps, tables=tables)
133+
json_string = await dump_to_json_string(fixture_configs=fixture_configs)
134+
print(json_string)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from __future__ import annotations
2+
3+
from piccolo.apps.fixture.commands.shared import (
4+
FixtureConfig,
5+
create_pydantic_fixture_model,
6+
)
7+
from piccolo.conf.apps import Finder
8+
from piccolo.engine import engine_finder
9+
from piccolo.utils.encoding import load_json
10+
11+
12+
async def load_json_string(json_string: str):
13+
"""
14+
Parses the JSON string, and inserts the parsed data into the database.
15+
"""
16+
# We have to deserialise the JSON to find out which apps and tables it
17+
# contains, so we can create a Pydantic model.
18+
# Then we let Pydantic do the proper deserialisation, as it does a much
19+
# better job of deserialising dates, datetimes, bytes etc.
20+
deserialised_contents = load_json(json_string)
21+
22+
app_names = deserialised_contents.keys()
23+
24+
fixture_configs = [
25+
FixtureConfig(
26+
app_name=app_name,
27+
table_class_names=[
28+
i for i in deserialised_contents[app_name].keys()
29+
],
30+
)
31+
for app_name in app_names
32+
]
33+
pydantic_model_class = create_pydantic_fixture_model(
34+
fixture_configs=fixture_configs
35+
)
36+
37+
fixture_pydantic_model = pydantic_model_class.parse_raw(json_string)
38+
39+
finder = Finder()
40+
engine = engine_finder()
41+
42+
if not engine:
43+
raise Exception("Unable to find the engine.")
44+
45+
async with engine.transaction():
46+
for app_name in app_names:
47+
app_model = getattr(fixture_pydantic_model, app_name)
48+
49+
for (
50+
table_class_name,
51+
model_instance_list,
52+
) in app_model.__dict__.items():
53+
table_class = finder.get_table_with_name(
54+
app_name, table_class_name
55+
)
56+
57+
await table_class.insert(
58+
*[
59+
table_class(**row.__dict__)
60+
for row in model_instance_list
61+
]
62+
).run()
63+
64+
65+
async def load(path: str = "fixture.json"):
66+
"""
67+
Reads the fixture file, and loads the contents into the database.
68+
"""
69+
with open(path, "r") as f:
70+
contents = f.read()
71+
72+
await load_json_string(contents)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
import typing as t
4+
from dataclasses import dataclass
5+
6+
import pydantic
7+
8+
from piccolo.conf.apps import Finder
9+
from piccolo.utils.pydantic import create_pydantic_model
10+
11+
if t.TYPE_CHECKING:
12+
from piccolo.table import Table
13+
14+
15+
@dataclass
16+
class FixtureConfig:
17+
app_name: str
18+
table_class_names: t.List[str]
19+
20+
21+
def create_pydantic_fixture_model(fixture_configs: t.List[FixtureConfig]):
22+
"""
23+
Returns a nested Pydantic model for serialising and deserialising fixtures.
24+
"""
25+
columns: t.Dict[str, t.Any] = {}
26+
27+
finder = Finder()
28+
29+
for fixture_config in fixture_configs:
30+
31+
app_columns: t.Dict[str, t.Any] = {}
32+
33+
for table_class_name in fixture_config.table_class_names:
34+
table_class: t.Type[Table] = finder.get_table_with_name(
35+
app_name=fixture_config.app_name,
36+
table_class_name=table_class_name,
37+
)
38+
app_columns[table_class_name] = (
39+
t.List[ # type: ignore
40+
create_pydantic_model(
41+
table_class, include_default_columns=True
42+
)
43+
],
44+
...,
45+
)
46+
47+
app_model: t.Any = pydantic.create_model(
48+
f"{fixture_config.app_name.title()}Model", **app_columns
49+
)
50+
51+
columns[fixture_config.app_name] = (app_model, ...)
52+
53+
model: t.Type[pydantic.BaseModel] = pydantic.create_model(
54+
"FixtureModel", **columns
55+
)
56+
57+
return model
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from piccolo.conf.apps import AppConfig
2+
3+
from .commands.dump import dump
4+
5+
APP_CONFIG = AppConfig(
6+
app_name="fixtures",
7+
migrations_folder_path="",
8+
table_classes=[],
9+
migration_dependencies=[],
10+
commands=[dump],
11+
)

piccolo/engine/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ async def run_querystring(self, querystring: QueryString, in_pool: bool):
5454
async def run_ddl(self, ddl: str, in_pool: bool = True):
5555
pass
5656

57+
@abstractmethod
58+
def transaction(self):
59+
pass
60+
5761
async def check_version(self):
5862
"""
5963
Warn if the database version isn't supported.

piccolo/engine/sqlite.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,21 @@ def convert_boolean_out(value: bytes) -> bool:
141141
return _value == "1"
142142

143143

144+
def convert_timestamp_out(value: bytes) -> datetime.datetime:
145+
"""
146+
If the value is from a timestamp column, convert it to a datetime value.
147+
"""
148+
return datetime.datetime.fromisoformat(value.decode("utf8"))
149+
150+
144151
def convert_timestamptz_out(value: bytes) -> datetime.datetime:
145152
"""
146-
If the value is from a timstamptz column, convert it to a datetime value,
153+
If the value is from a timestamptz column, convert it to a datetime value,
147154
with a timezone of UTC.
148155
"""
149-
return datetime.datetime.fromisoformat(value.decode("utf8"))
156+
_value = datetime.datetime.fromisoformat(value.decode("utf8"))
157+
_value = _value.replace(tzinfo=datetime.timezone.utc)
158+
return _value
150159

151160

152161
def convert_array_out(value: bytes) -> t.List:
@@ -164,6 +173,7 @@ def convert_array_out(value: bytes) -> t.List:
164173
sqlite3.register_converter("Time", convert_time_out)
165174
sqlite3.register_converter("Seconds", convert_seconds_out)
166175
sqlite3.register_converter("Boolean", convert_boolean_out)
176+
sqlite3.register_converter("Timestamp", convert_timestamp_out)
167177
sqlite3.register_converter("Timestamptz", convert_timestamptz_out)
168178
sqlite3.register_converter("Array", convert_array_out)
169179

0 commit comments

Comments
 (0)