Coverage for src/typedal/mixins.py: 100%

1"""

2 This file contains example Mixins.

3

4 Mixins can add reusable fields and behavior (optimally both, otherwise it doesn't add much).

5"""

6

7 import base64

8 import os

9 import typing

10 import warnings

11 from datetime import datetime

12 from typing import Any, Optional

13

14 from slugify import slugify

15

16 from .core import ( # noqa F401 - used by example in docstring

17 QueryBuilder,

18 T_MetaInstance,

19 TableMeta,

20 TypeDAL,

21 TypedTable,

22 _TypedTable,

23)

24 from .fields import DatetimeField, StringField

25 from .types import OpRow, Set

26

27

28 class Mixin(_TypedTable):

29 """

30 A mixin should be derived from this class.

31

32 The mixin base class itself doesn't do anything,

33 but using it makes sure the mixin fields are placed AFTER the table's normal fields (instead of before)

34

35 During runtime, mixin should not have a base class in order to prevent MRO issues

36 ('inconsistent method resolution' or 'metaclass conflicts')

37 """

38

39

40 class TimestampsMixin(Mixin):

41 """

42 A Mixin class for adding timestamp fields to a model.

43 """

44

45 created_at = DatetimeField(default=datetime.now, writable=False)

46 updated_at = DatetimeField(default=datetime.now, writable=False)

47

48 @classmethod

49 def __on_define__(cls, db: TypeDAL) -> None:

50 """

51 Hook called when defining the model to initialize timestamps.

52

53 Args:

54 db (TypeDAL): The database layer.

55 """

56 super().__on_define__(db)

57

58 def set_updated_at(_: Set, row: OpRow) -> None:

59 """

60 Callback function to update the 'updated_at' field before saving changes.

61

62 Args:

63 _: Set: Unused parameter.

64 row (OpRow): The row to update.

65 """

66 row["updated_at"] = datetime.now()

67

68 cls._before_update.append(set_updated_at)

69

70

71 def slug_random_suffix(length: int = 8) -> str:

72 """

73 Generate a random suffix to make slugs unique, even when titles are the same.

74

75 UUID4 uses 16 bytes, but 8 is probably more than enough given you probably don't have THAT much duplicate titles.

76 Strip away '=' to make it URL-safe

77 (even though 'urlsafe_b64encode' sounds like it should already be url-safe - it is not)

78 """

79 return base64.urlsafe_b64encode(os.urandom(length)).rstrip(b"=").decode().strip("=")

80

81

82 class SlugMixin(Mixin):

83 """

84 (Opinionated) example mixin to add a 'slug' field, which depends on a user-provided other field.

85

86 Some random bytes are added at the end to prevent duplicates.

87

88 Example:

89 >>> class MyTable(TypedTable, SlugMixin, slug_field="some_name", slug_suffix_length=8):

90 >>> some_name: str

91 >>> ...

92 """

93

94 # pub:

95 slug = StringField(unique=True, writable=False)

96 # priv:

97 __settings__: typing.TypedDict( # type: ignore

98 "SlugFieldSettings",

99 {

100 "slug_field": str,

101 "slug_suffix": int,

102 },

103 ) # set via init subclass

104

105 def __init_subclass__(cls, slug_field: str = None, slug_suffix_length: int = 0, **kw: Any) -> None:

106 """

107 Bind 'slug field' option to be used later (on_define).

108

109 You can control the length of the random suffix with the `slug_suffix_length` option (0 is no suffix).

110 """

111 # unfortunately, PyCharm and mypy do not recognize/autocomplete/typecheck init subclass (keyword) arguments.

112 if slug_field is None:

113 raise ValueError(

114 "SlugMixin requires a valid slug_field setting: "

115 "e.g. `class MyClass(TypedTable, SlugMixin, slug_field='title'): ...`"

116 )

117

118 if "slug_suffix" in kw:

119 warnings.warn(

120 "The 'slug_suffix' option is deprecated, use 'slug_suffix_length' instead.",

121 DeprecationWarning,

122 )

123

124 slug_suffix = slug_suffix_length or kw.get("slug_suffix", 0)

125

126 cls.__settings__ = {

127 "slug_field": slug_field,

128 "slug_suffix": slug_suffix,

129 }

130

131 @classmethod

132 def __on_define__(cls, db: TypeDAL) -> None:

133 """

134 When db is available, include a before_insert hook to generate and include a slug.

135 """

136 super().__on_define__(db)

137

138 # slugs should not be editable (for SEO reasons), so there is only a before insert hook:

139 def generate_slug_before_insert(row: OpRow) -> None:

140 settings = cls.__settings__

141

142 text_input = row[settings["slug_field"]]

143 generated_slug = slugify(text_input)

144

145 if suffix_len := settings["slug_suffix"]:

146 generated_slug += f"-{slug_random_suffix(suffix_len)}"

147

148 row["slug"] = slugify(generated_slug)

149

150 cls._before_insert.append(generate_slug_before_insert)

151

152 @classmethod

153 def from_slug(cls: typing.Type[T_MetaInstance], slug: str, join: bool = True) -> Optional[T_MetaInstance]:

154 """

155 Find a row by its slug.

156 """

157 builder = cls.where(slug=slug)

158 if join:

159 builder = builder.join()

160

161 return builder.first()

162

163 @classmethod

164 def from_slug_or_fail(cls: typing.Type[T_MetaInstance], slug: str, join: bool = True) -> T_MetaInstance:

165 """

166 Find a row by its slug, or raise an error if it doesn't exist.

167 """

168 builder = cls.where(slug=slug)

169 if join:

170 builder = builder.join()

171

172 return builder.first_or_fail()