piccolo 1.10.0__py3-none-any.whl → 1.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. piccolo/__init__.py +1 -1
  2. piccolo/apps/asgi/commands/templates/app/_fastapi_app.py.jinja +35 -30
  3. piccolo/apps/asgi/commands/templates/app/_starlette_app.py.jinja +29 -21
  4. piccolo/apps/migrations/auto/migration_manager.py +1 -0
  5. piccolo/apps/migrations/commands/forwards.py +8 -7
  6. piccolo/apps/playground/commands/run.py +11 -0
  7. piccolo/columns/column_types.py +83 -35
  8. piccolo/query/functions/__init__.py +11 -1
  9. piccolo/query/functions/datetime.py +260 -0
  10. piccolo/query/functions/string.py +45 -0
  11. {piccolo-1.10.0.dist-info → piccolo-1.12.0.dist-info}/METADATA +15 -15
  12. {piccolo-1.10.0.dist-info → piccolo-1.12.0.dist-info}/RECORD +42 -40
  13. {piccolo-1.10.0.dist-info → piccolo-1.12.0.dist-info}/WHEEL +1 -1
  14. tests/apps/migrations/commands/test_forwards_backwards.py +32 -1
  15. tests/columns/test_array.py +3 -7
  16. tests/columns/test_bigint.py +3 -9
  17. tests/columns/test_boolean.py +3 -7
  18. tests/columns/test_bytea.py +5 -14
  19. tests/columns/test_choices.py +5 -14
  20. tests/columns/test_date.py +5 -13
  21. tests/columns/test_double_precision.py +3 -8
  22. tests/columns/test_interval.py +5 -13
  23. tests/columns/test_json.py +9 -26
  24. tests/columns/test_jsonb.py +3 -11
  25. tests/columns/test_numeric.py +3 -7
  26. tests/columns/test_primary_key.py +11 -33
  27. tests/columns/test_readable.py +5 -7
  28. tests/columns/test_real.py +3 -8
  29. tests/columns/test_reserved_column_names.py +3 -8
  30. tests/columns/test_smallint.py +3 -8
  31. tests/columns/test_time.py +5 -14
  32. tests/columns/test_timestamp.py +5 -13
  33. tests/columns/test_timestamptz.py +5 -13
  34. tests/columns/test_uuid.py +3 -7
  35. tests/columns/test_varchar.py +3 -9
  36. tests/query/functions/base.py +2 -15
  37. tests/query/functions/test_datetime.py +112 -0
  38. tests/query/functions/test_math.py +2 -3
  39. tests/query/functions/test_string.py +34 -2
  40. {piccolo-1.10.0.dist-info → piccolo-1.12.0.dist-info}/LICENSE +0 -0
  41. {piccolo-1.10.0.dist-info → piccolo-1.12.0.dist-info}/entry_points.txt +0 -0
  42. {piccolo-1.10.0.dist-info → piccolo-1.12.0.dist-info}/top_level.txt +0 -0
piccolo/__init__.py CHANGED
@@ -1 +1 @@
1
- __VERSION__ = "1.10.0"
1
+ __VERSION__ = "1.12.0"
@@ -1,11 +1,12 @@
1
1
  import typing as t
2
+ from contextlib import asynccontextmanager
2
3
 
3
4
  from fastapi import FastAPI
4
5
  from fastapi.responses import JSONResponse
6
+ from piccolo.engine import engine_finder
5
7
  from piccolo_admin.endpoints import create_admin
6
8
  from piccolo_api.crud.serializers import create_pydantic_model
7
- from piccolo.engine import engine_finder
8
- from starlette.routing import Route, Mount
9
+ from starlette.routing import Mount, Route
9
10
  from starlette.staticfiles import StaticFiles
10
11
 
11
12
  from home.endpoints import HomeEndpoint
@@ -13,6 +14,29 @@ from home.piccolo_app import APP_CONFIG
13
14
  from home.tables import Task
14
15
 
15
16
 
17
+ async def open_database_connection_pool():
18
+ try:
19
+ engine = engine_finder()
20
+ await engine.start_connection_pool()
21
+ except Exception:
22
+ print("Unable to connect to the database")
23
+
24
+
25
+ async def close_database_connection_pool():
26
+ try:
27
+ engine = engine_finder()
28
+ await engine.close_connection_pool()
29
+ except Exception:
30
+ print("Unable to connect to the database")
31
+
32
+
33
+ @asynccontextmanager
34
+ async def lifespan(app: FastAPI):
35
+ await open_database_connection_pool()
36
+ yield
37
+ await close_database_connection_pool()
38
+
39
+
16
40
  app = FastAPI(
17
41
  routes=[
18
42
  Route("/", HomeEndpoint),
@@ -22,21 +46,20 @@ app = FastAPI(
22
46
  tables=APP_CONFIG.table_classes,
23
47
  # Required when running under HTTPS:
24
48
  # allowed_hosts=['my_site.com']
25
- )
49
+ ),
26
50
  ),
27
51
  Mount("/static/", StaticFiles(directory="static")),
28
52
  ],
53
+ lifespan=lifespan,
29
54
  )
30
55
 
31
56
 
32
57
  TaskModelIn: t.Any = create_pydantic_model(
33
58
  table=Task,
34
- model_name='TaskModelIn'
59
+ model_name="TaskModelIn",
35
60
  )
36
61
  TaskModelOut: t.Any = create_pydantic_model(
37
- table=Task,
38
- include_default_columns=True,
39
- model_name='TaskModelOut'
62
+ table=Task, include_default_columns=True, model_name="TaskModelOut"
40
63
  )
41
64
 
42
65
 
@@ -45,16 +68,16 @@ async def tasks():
45
68
  return await Task.select().order_by(Task.id)
46
69
 
47
70
 
48
- @app.post('/tasks/', response_model=TaskModelOut)
71
+ @app.post("/tasks/", response_model=TaskModelOut)
49
72
  async def create_task(task_model: TaskModelIn):
50
73
  task = Task(**task_model.dict())
51
74
  await task.save()
52
75
  return task.to_dict()
53
76
 
54
77
 
55
- @app.put('/tasks/{task_id}/', response_model=TaskModelOut)
78
+ @app.put("/tasks/{task_id}/", response_model=TaskModelOut)
56
79
  async def update_task(task_id: int, task_model: TaskModelIn):
57
- task = await Task.objects().get(Task.id == task_id)
80
+ task = await Task.objects().get(Task._meta.primary_key == task_id)
58
81
  if not task:
59
82
  return JSONResponse({}, status_code=404)
60
83
 
@@ -66,30 +89,12 @@ async def update_task(task_id: int, task_model: TaskModelIn):
66
89
  return task.to_dict()
67
90
 
68
91
 
69
- @app.delete('/tasks/{task_id}/')
92
+ @app.delete("/tasks/{task_id}/")
70
93
  async def delete_task(task_id: int):
71
- task = await Task.objects().get(Task.id == task_id)
94
+ task = await Task.objects().get(Task._meta.primary_key == task_id)
72
95
  if not task:
73
96
  return JSONResponse({}, status_code=404)
74
97
 
75
98
  await task.remove()
76
99
 
77
100
  return JSONResponse({})
78
-
79
-
80
- @app.on_event("startup")
81
- async def open_database_connection_pool():
82
- try:
83
- engine = engine_finder()
84
- await engine.start_connection_pool()
85
- except Exception:
86
- print("Unable to connect to the database")
87
-
88
-
89
- @app.on_event("shutdown")
90
- async def close_database_connection_pool():
91
- try:
92
- engine = engine_finder()
93
- await engine.close_connection_pool()
94
- except Exception:
95
- print("Unable to connect to the database")
@@ -1,8 +1,10 @@
1
+ from contextlib import asynccontextmanager
2
+
3
+ from piccolo.engine import engine_finder
1
4
  from piccolo_admin.endpoints import create_admin
2
5
  from piccolo_api.crud.endpoints import PiccoloCRUD
3
- from piccolo.engine import engine_finder
4
- from starlette.routing import Route, Mount
5
6
  from starlette.applications import Starlette
7
+ from starlette.routing import Mount, Route
6
8
  from starlette.staticfiles import StaticFiles
7
9
 
8
10
  from home.endpoints import HomeEndpoint
@@ -10,24 +12,6 @@ from home.piccolo_app import APP_CONFIG
10
12
  from home.tables import Task
11
13
 
12
14
 
13
- app = Starlette(
14
- routes=[
15
- Route("/", HomeEndpoint),
16
- Mount(
17
- "/admin/",
18
- create_admin(
19
- tables=APP_CONFIG.table_classes,
20
- # Required when running under HTTPS:
21
- # allowed_hosts=['my_site.com']
22
- )
23
- ),
24
- Mount("/static/", StaticFiles(directory="static")),
25
- Mount("/tasks/", PiccoloCRUD(table=Task))
26
- ],
27
- )
28
-
29
-
30
- @app.on_event("startup")
31
15
  async def open_database_connection_pool():
32
16
  try:
33
17
  engine = engine_finder()
@@ -36,10 +20,34 @@ async def open_database_connection_pool():
36
20
  print("Unable to connect to the database")
37
21
 
38
22
 
39
- @app.on_event("shutdown")
40
23
  async def close_database_connection_pool():
41
24
  try:
42
25
  engine = engine_finder()
43
26
  await engine.close_connection_pool()
44
27
  except Exception:
45
28
  print("Unable to connect to the database")
29
+
30
+
31
+ @asynccontextmanager
32
+ async def lifespan(app: Starlette):
33
+ await open_database_connection_pool()
34
+ yield
35
+ await close_database_connection_pool()
36
+
37
+
38
+ app = Starlette(
39
+ routes=[
40
+ Route("/", HomeEndpoint),
41
+ Mount(
42
+ "/admin/",
43
+ create_admin(
44
+ tables=APP_CONFIG.table_classes,
45
+ # Required when running under HTTPS:
46
+ # allowed_hosts=['my_site.com']
47
+ ),
48
+ ),
49
+ Mount("/static/", StaticFiles(directory="static")),
50
+ Mount("/tasks/", PiccoloCRUD(table=Task)),
51
+ ],
52
+ lifespan=lifespan,
53
+ )
@@ -165,6 +165,7 @@ class MigrationManager:
165
165
  raw_backwards: t.List[t.Union[t.Callable, AsyncFunction]] = field(
166
166
  default_factory=list
167
167
  )
168
+ fake: bool = False
168
169
 
169
170
  def add_table(
170
171
  self,
@@ -72,18 +72,19 @@ class ForwardsMigrationManager(BaseMigrationManager):
72
72
  print(f"🚀 Running {n} migration{'s' if n != 1 else ''}:")
73
73
 
74
74
  for _id in subset:
75
- if self.fake:
76
- print(f"- {_id}: faked! ⏭️")
77
- else:
78
- migration_module = migration_modules[_id]
79
- response = await migration_module.forwards()
75
+ migration_module = migration_modules[_id]
76
+ response = await migration_module.forwards()
80
77
 
81
- if isinstance(response, MigrationManager):
78
+ if isinstance(response, MigrationManager):
79
+ if self.fake or response.fake:
80
+ print(f"- {_id}: faked! ⏭️")
81
+ else:
82
82
  if self.preview:
83
83
  response.preview = True
84
84
  await response.run()
85
85
 
86
- print("ok! ✔️")
86
+ print("ok! ✔️")
87
+
87
88
  if not self.preview:
88
89
  await Migration.insert().add(
89
90
  Migration(name=_id, app_name=app_config.app_name)
@@ -19,6 +19,7 @@ from piccolo.columns import (
19
19
  Interval,
20
20
  Numeric,
21
21
  Serial,
22
+ Text,
22
23
  Timestamp,
23
24
  Varchar,
24
25
  )
@@ -55,6 +56,12 @@ class Band(Table):
55
56
  )
56
57
 
57
58
 
59
+ class FanClub(Table):
60
+ id: Serial
61
+ address = Text()
62
+ band = ForeignKey(Band, unique=True)
63
+
64
+
58
65
  class Venue(Table):
59
66
  id: Serial
60
67
  name = Varchar(length=100)
@@ -154,6 +161,7 @@ class Album(Table):
154
161
  TABLES = (
155
162
  Manager,
156
163
  Band,
164
+ FanClub,
157
165
  Venue,
158
166
  Concert,
159
167
  Ticket,
@@ -185,6 +193,9 @@ def populate():
185
193
  pythonistas = Band(name="Pythonistas", manager=guido.id, popularity=1000)
186
194
  pythonistas.save().run_sync()
187
195
 
196
+ fan_club = FanClub(address="1 Flying Circus, UK", band=pythonistas)
197
+ fan_club.save().run_sync()
198
+
188
199
  graydon = Manager(name="Graydon")
189
200
  graydon.save().run_sync()
190
201
 
@@ -86,47 +86,38 @@ class ConcatDelegate:
86
86
 
87
87
  def get_querystring(
88
88
  self,
89
- column_name: str,
90
- value: t.Union[str, Varchar, Text],
89
+ column: Column,
90
+ value: t.Union[str, Column, QueryString],
91
91
  reverse: bool = False,
92
92
  ) -> QueryString:
93
- if isinstance(value, (Varchar, Text)):
94
- column: Column = value
93
+ """
94
+ :param reverse:
95
+ By default the value is appended to the column's value. If
96
+ ``reverse=True`` then the value is prepended to the column's
97
+ value instead.
98
+
99
+ """
100
+ if isinstance(value, Column):
95
101
  if len(column._meta.call_chain) > 0:
96
102
  raise ValueError(
97
103
  "Adding values across joins isn't currently supported."
98
104
  )
99
- other_column_name = column._meta.db_column_name
100
- if reverse:
101
- return QueryString(
102
- Concat.template.format(
103
- value_1=other_column_name, value_2=column_name
104
- )
105
- )
106
- else:
107
- return QueryString(
108
- Concat.template.format(
109
- value_1=column_name, value_2=other_column_name
110
- )
111
- )
112
105
  elif isinstance(value, str):
113
- if reverse:
114
- value_1 = QueryString("CAST({} AS text)", value)
115
- return QueryString(
116
- Concat.template.format(value_1="{}", value_2=column_name),
117
- value_1,
118
- )
119
- else:
120
- value_2 = QueryString("CAST({} AS text)", value)
121
- return QueryString(
122
- Concat.template.format(value_1=column_name, value_2="{}"),
123
- value_2,
124
- )
125
- else:
106
+ value = QueryString("CAST({} AS TEXT)", value)
107
+ elif not isinstance(value, QueryString):
126
108
  raise ValueError(
127
- "Only str, Varchar columns, and Text columns can be added."
109
+ "Only str, Column and QueryString values can be added."
128
110
  )
129
111
 
112
+ args = [value, column] if reverse else [column, value]
113
+
114
+ # We use the concat operator instead of the concat function, because
115
+ # this is what we historically used, and they treat null values
116
+ # differently.
117
+ return QueryString(
118
+ Concat.template.format(value_1="{}", value_2="{}"), *args
119
+ )
120
+
130
121
 
131
122
  class MathDelegate:
132
123
  """
@@ -340,12 +331,13 @@ class Varchar(Column):
340
331
 
341
332
  def __add__(self, value: t.Union[str, Varchar, Text]) -> QueryString:
342
333
  return self.concat_delegate.get_querystring(
343
- column_name=self._meta.db_column_name, value=value
334
+ column=self,
335
+ value=value,
344
336
  )
345
337
 
346
338
  def __radd__(self, value: t.Union[str, Varchar, Text]) -> QueryString:
347
339
  return self.concat_delegate.get_querystring(
348
- column_name=self._meta.db_column_name,
340
+ column=self,
349
341
  value=value,
350
342
  reverse=True,
351
343
  )
@@ -442,12 +434,13 @@ class Text(Column):
442
434
 
443
435
  def __add__(self, value: t.Union[str, Varchar, Text]) -> QueryString:
444
436
  return self.concat_delegate.get_querystring(
445
- column_name=self._meta.db_column_name, value=value
437
+ column=self,
438
+ value=value,
446
439
  )
447
440
 
448
441
  def __radd__(self, value: t.Union[str, Varchar, Text]) -> QueryString:
449
442
  return self.concat_delegate.get_querystring(
450
- column_name=self._meta.db_column_name,
443
+ column=self,
451
444
  value=value,
452
445
  reverse=True,
453
446
  )
@@ -2023,6 +2016,61 @@ class ForeignKey(Column, t.Generic[ReferencedTable]):
2023
2016
  if column._meta.name not in excluded_column_names
2024
2017
  ]
2025
2018
 
2019
+ def reverse(self) -> ForeignKey:
2020
+ """
2021
+ If there's a unique foreign key, this function reverses it.
2022
+
2023
+ .. code-block:: python
2024
+
2025
+ class Band(Table):
2026
+ name = Varchar()
2027
+
2028
+ class FanClub(Table):
2029
+ band = ForeignKey(Band, unique=True)
2030
+ address = Text()
2031
+
2032
+ class Treasurer(Table):
2033
+ fan_club = ForeignKey(FanClub, unique=True)
2034
+ name = Varchar()
2035
+
2036
+ It's helpful with ``get_related``, for example:
2037
+
2038
+ .. code-block:: python
2039
+
2040
+ >>> band = await Band.objects().first()
2041
+ >>> await band.get_related(FanClub.band.reverse())
2042
+ <Fan Club: 1>
2043
+
2044
+ It works multiple levels deep:
2045
+
2046
+ .. code-block:: python
2047
+
2048
+ >>> await band.get_related(Treasurer.fan_club._.band.reverse())
2049
+ <Treasurer: 1>
2050
+
2051
+ """
2052
+ if not self._meta.unique or any(
2053
+ not i._meta.unique for i in self._meta.call_chain
2054
+ ):
2055
+ raise ValueError("Only reverse unique foreign keys.")
2056
+
2057
+ foreign_keys = [*self._meta.call_chain, self]
2058
+
2059
+ root_foreign_key = foreign_keys[0]
2060
+ target_column = (
2061
+ root_foreign_key._foreign_key_meta.resolved_target_column
2062
+ )
2063
+ foreign_key = target_column.join_on(root_foreign_key)
2064
+
2065
+ call_chain = []
2066
+ for fk in reversed(foreign_keys[1:]):
2067
+ target_column = fk._foreign_key_meta.resolved_target_column
2068
+ call_chain.append(target_column.join_on(fk))
2069
+
2070
+ foreign_key._meta.call_chain = call_chain
2071
+
2072
+ return foreign_key
2073
+
2026
2074
  def all_related(
2027
2075
  self, exclude: t.Optional[t.List[t.Union[ForeignKey, str]]] = None
2028
2076
  ) -> t.List[ForeignKey]:
@@ -1,6 +1,7 @@
1
1
  from .aggregate import Avg, Count, Max, Min, Sum
2
+ from .datetime import Day, Extract, Hour, Month, Second, Strftime, Year
2
3
  from .math import Abs, Ceil, Floor, Round
3
- from .string import Length, Lower, Ltrim, Reverse, Rtrim, Upper
4
+ from .string import Concat, Length, Lower, Ltrim, Reverse, Rtrim, Upper
4
5
  from .type_conversion import Cast
5
6
 
6
7
  __all__ = (
@@ -8,16 +9,25 @@ __all__ = (
8
9
  "Avg",
9
10
  "Cast",
10
11
  "Ceil",
12
+ "Concat",
11
13
  "Count",
14
+ "Day",
15
+ "Extract",
16
+ "Extract",
12
17
  "Floor",
18
+ "Hour",
13
19
  "Length",
14
20
  "Lower",
15
21
  "Ltrim",
16
22
  "Max",
17
23
  "Min",
24
+ "Month",
18
25
  "Reverse",
19
26
  "Round",
20
27
  "Rtrim",
28
+ "Second",
29
+ "Strftime",
21
30
  "Sum",
22
31
  "Upper",
32
+ "Year",
23
33
  )