Coverage for src/typedal/core.py: 100%
119 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-05 16:10 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-05 16:10 +0200
1"""
2Core functionality of TypeDAL.
3"""
5import datetime as dt
6import types
7import typing
8from decimal import Decimal
10import pydal
11from pydal.objects import Field, Query, Row, Rows, Table
13# use typing.cast(type, ...) to make mypy happy with unions
14T_annotation = typing.Type[typing.Any] | types.UnionType
16BASIC_MAPPINGS: dict[T_annotation, str] = {
17 str: "string",
18 int: "integer",
19 bool: "boolean",
20 bytes: "blob",
21 float: "double",
22 object: "json",
23 Decimal: "decimal(10,2)",
24 dt.date: "date",
25 dt.time: "time",
26 dt.datetime: "datetime",
27}
30class _Types:
31 """
32 Internal type storage for stuff that mypy otherwise won't understand.
33 """
35 NONETYPE = type(None)
38# the input and output of TypeDAL.define
39T = typing.TypeVar("T", typing.Type["TypedTable"], typing.Type["Table"])
42def is_union(some_type: type) -> bool:
43 """
44 Check if a type is some type of Union.
46 Args:
47 some_type: types.UnionType = type(int | str); typing.Union = typing.Union[int, str]
49 """
50 return typing.get_origin(some_type) in (types.UnionType, typing.Union)
53class TypeDAL(pydal.DAL): # type: ignore
54 """
55 Drop-in replacement for pyDAL with layer to convert class-based table definitions to classical pydal define_tables.
56 """
58 dal: Table
60 default_kwargs = {
61 # fields are 'required' (notnull) by default:
62 "notnull": True,
63 }
65 def define(self, cls: T) -> Table:
66 """
67 Can be used as a decorator on a class that inherits `TypedTable`, \
68 or as a regular method if you need to define your classes before you have access to a 'db' instance.
70 Args:
71 cls:
73 Example:
74 @db.define
75 class Person(TypedTable):
76 ...
78 class Article(TypedTable):
79 ...
81 # at a later time:
82 db.define(Article)
84 Returns:
85 the result of pydal.define_table
86 """
87 # when __future__.annotations is implemented, cls.__annotations__ will not work anymore as below.
88 # proper way to handle this would be (but gives error right now due to Table implementing magic methods):
89 # typing.get_type_hints(cls, globalns=None, localns=None)
91 # dirty way (with evil eval):
92 # [eval(v) for k, v in cls.__annotations__.items()]
93 # this however also stops working when variables outside this scope or even references to other
94 # objects are used. So for now, this package will NOT work when from __future__ import annotations is used,
95 # and might break in the future, when this annotations behavior is enabled by default.
97 # non-annotated variables have to be passed to define_table as kwargs
99 tablename = self._to_snake(cls.__name__)
100 fields = [self._to_field(fname, ftype) for fname, ftype in cls.__annotations__.items()]
101 other_kwargs = {k: v for k, v in cls.__dict__.items() if k not in cls.__annotations__ and not k.startswith("_")}
103 table: Table = self.define_table(tablename, *fields, **other_kwargs)
105 cls.__set_internals__(db=self, table=table)
107 # the ACTUAL output is not TypedTable but rather pydal.Table
108 # but telling the editor it is T helps with hinting.
109 return table
111 def __call__(self, *_args: Query, **kwargs: typing.Any) -> pydal.objects.Set:
112 """
113 A db instance can be called directly to perform a query.
115 Usually, only a query is passed
117 Example:
118 db(query).select()
120 """
121 args = list(_args)
122 if args:
123 cls = args[0]
124 if issubclass(type(cls), type) and issubclass(cls, TypedTable):
125 # table defined without @db.define decorator!
126 args[0] = cls.id != None
128 return super().__call__(*args, **kwargs)
130 # todo: insert etc shadowen?
132 @classmethod
133 def _build_field(cls, name: str, _type: str, **kw: typing.Any) -> Field:
134 return Field(name, _type, **{**cls.default_kwargs, **kw})
136 @classmethod
137 def _annotation_to_pydal_fieldtype(
138 cls, _ftype: T_annotation, mut_kw: typing.MutableMapping[str, typing.Any]
139 ) -> typing.Optional[str]:
140 # ftype can be a union or type. typing.cast is sometimes used to tell mypy when it's not a union.
141 ftype = typing.cast(type, _ftype) # cast from typing.Type to type to make mypy happy)
143 if mapping := BASIC_MAPPINGS.get(ftype):
144 # basi types
145 return mapping
146 elif isinstance(ftype, Table):
147 # db.table
148 return f"reference {ftype._tablename}"
149 elif issubclass(type(ftype), type) and issubclass(ftype, TypedTable):
150 # SomeTable
151 snakename = cls._to_snake(ftype.__name__)
152 return f"reference {snakename}"
153 elif isinstance(ftype, TypedFieldType):
154 # FieldType(type, ...)
155 return ftype._to_field(mut_kw)
156 elif isinstance(ftype, types.GenericAlias) and typing.get_origin(ftype) is list:
157 # list[str] -> str -> string -> list:string
158 _child_type = typing.get_args(ftype)[0]
159 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw)
160 return f"list:{_child_type}"
161 elif is_union(ftype):
162 # str | int -> UnionType
163 # typing.Union[str | int] -> typing._UnionGenericAlias
165 # typing.Optional[type] == type | None
167 match typing.get_args(ftype):
168 case (_child_type, _Types.NONETYPE):
169 # good union of Nullable
171 # if a field is optional, it is nullable:
172 mut_kw["notnull"] = False
173 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw)
174 case _:
175 # two types is not supported by the db!
176 return None
177 else:
178 return None
180 @classmethod
181 def _to_field(cls, fname: str, ftype: type, **kw: typing.Any) -> Field:
182 """
183 Convert a annotation into a pydal Field.
185 Args:
186 fname: name of the property
187 ftype: annotation of the property
188 kw: when using TypedField or a function returning it (e.g. StringField),
189 keyword args can be used to pass any other settings you would normally to a pydal Field
191 -> pydal.Field(fname, ftype, **kw)
193 Example:
194 class MyTable:
195 fname: ftype
196 id: int
197 name: str
198 reference: Table
199 other: TypedField(str, default="John Doe") # default will be in kwargs
200 """
201 fname = cls._to_snake(fname)
203 converted_type = cls._annotation_to_pydal_fieldtype(ftype, kw)
204 if not converted_type:
205 raise NotImplementedError(f"Unsupported type {ftype}/{type(ftype)}")
207 return cls._build_field(fname, converted_type, **kw)
209 @staticmethod
210 def _to_snake(camel: str) -> str:
211 # https://stackoverflow.com/a/44969381
212 return "".join([f"_{c.lower()}" if c.isupper() else c for c in camel]).lstrip("_")
215class TypedTableMeta(type):
216 """
217 Meta class allows getattribute on class variables instead instance variables.
219 Used in `class TypedTable(Table, metaclass=TypedTableMeta)`
220 """
222 def __getattr__(self, key: str) -> Field:
223 """
224 The getattr method is only called when getattribute can't find something.
226 `__get_table_column__` is defined in `TypedTable`
227 """
228 return self.__get_table_column__(key)
231class TypedTable(Table, metaclass=TypedTableMeta): # type: ignore
232 """
233 Typed version of pydal.Table, does not really do anything itself but forwards logic to pydal.
234 """
236 id: int # noqa: 'id' has to be id since that's the db column
238 # set up by db.define:
239 __db: TypeDAL | None = None
240 __table: Table | None = None
242 @classmethod
243 def __set_internals__(cls, db: pydal.DAL, table: Table) -> None:
244 """
245 Store the related database and pydal table for later usage.
246 """
247 cls.__db = db
248 cls.__table = table
250 @classmethod
251 def __get_table_column__(cls, col: str) -> Field:
252 """
253 Magic method used by TypedTableMeta to get a database field with dot notation on a class.
255 Example:
256 SomeTypedTable.col -> db.table.col (via TypedTableMeta.__getattr__)
258 """
259 #
260 if cls.__table:
261 return cls.__table[col]
263 def __new__(cls, *a: typing.Any, **kw: typing.Any) -> Row: # or none!
264 """
265 When e.g. Table(id=0) is called without db.define, \
266 this catches it and forwards for proper behavior.
268 Args:
269 *a: can be for example Table(<id>)
270 **kw: can be for example Table(slug=<slug>)
271 """
272 if not cls.__table:
273 raise EnvironmentError("@define or db.define is not called on this class yet!")
274 return cls.__table(*a, **kw)
276 @classmethod
277 def insert(cls, **fields: typing.Any) -> int:
278 """
279 This is only called when db.define is not used as a decorator.
281 cls.__table functions as 'self'
283 Args:
284 **fields: anything you want to insert in the database
286 Returns: the ID of the new row.
288 """
289 if not cls.__table:
290 raise EnvironmentError("@define or db.define is not called on this class yet!")
292 result = super().insert(cls.__table, **fields)
293 # it already is an int but mypy doesn't understand that
294 return typing.cast(int, result)
297# backwards compat:
298TypedRow = TypedTable
301class TypedFieldType(Field): # type: ignore
302 """
303 Typed version of pydal.Field, which will be converted to a normal Field in the background.
304 """
306 _table = "<any table>"
307 _type: T_annotation
308 kwargs: typing.Any
310 def __init__(self, _type: T_annotation, **kwargs: typing.Any) -> None:
311 """
312 A TypedFieldType should not be inited manually, but TypedField (from `fields.py`) should be used!
313 """
314 self._type = _type
315 self.kwargs = kwargs
317 def __str__(self) -> str:
318 """
319 String representation of a Typed Field.
321 If `type` is set explicitly (e.g. TypedField(str, type="text")), that type is used: `TypedField.text`,
322 otherwise the type annotation is used (e.g. TypedField(str) -> TypedField.str)
323 """
324 if "type" in self.kwargs:
325 # manual type in kwargs supplied
326 t = self.kwargs["type"]
327 elif issubclass(type, type(self._type)):
328 # normal type, str.__name__ = 'str'
329 t = getattr(self._type, "__name__", str(self._type))
330 elif t_args := typing.get_args(self._type):
331 # list[str] -> 'str'
332 t = t_args[0].__name__
333 else: # pragma: no cover
334 # fallback - something else, may not even happen, I'm not sure
335 t = self._type
336 return f"TypedField.{t}"
338 def __repr__(self) -> str:
339 """
340 More detailed string representation of a Typed Field.
342 Uses __str__ and adds the provided extra options (kwargs) in the representation.
343 """
344 s = self.__str__()
345 kw = self.kwargs.copy()
346 kw.pop("type", None)
347 return f"<{s} with options {kw}>"
349 def _to_field(self, extra_kwargs: typing.MutableMapping[str, typing.Any]) -> typing.Optional[str]:
350 """
351 Convert a Typed Field instance to a pydal.Field.
352 """
353 other_kwargs = self.kwargs.copy()
354 extra_kwargs.update(other_kwargs)
355 return extra_kwargs.pop("type", False) or TypeDAL._annotation_to_pydal_fieldtype(self._type, extra_kwargs)
358S = typing.TypeVar("S")
361class TypedRows(typing.Collection[S], Rows): # type: ignore
362 """
363 Can be used as the return type of a .select().
365 Example:
366 people: TypedRows[Person] = db(Person).select()
367 """