Skip to content

Commit ff70a25

Browse files
authored
Show MetaClass field binding and schema (#214)
* Bind names to metaclass fields at runtime * Make primary key selection more robust * Generate table DDL given its payload * Make field constructors more realistic * Satisfy mypy and ruff linting * Fix BaseField within meta_class * Improve meta_class code coverage
1 parent caa81f7 commit ff70a25

1 file changed

Lines changed: 105 additions & 8 deletions

File tree

‎ultimatepython/advanced/meta_class.py‎

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,12 @@ def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, Any]) -> "
7171
if isinstance(base, ModelMeta):
7272
kls.model_fields.update(base.model_fields)
7373

74-
# Fill model fields from itself
75-
kls.model_fields.update({field_name: field_obj for field_name, field_obj in attrs.items() if isinstance(field_obj, BaseField)})
74+
# Fill model fields from itself. Each field is "late bound" to its
75+
# declared attribute name here: the field object had no name when it
76+
# was constructed, so we hand it the name at class creation time
77+
for field_name, field_obj in attrs.items():
78+
if isinstance(field_obj, BaseField):
79+
kls.model_fields[field_name] = field_obj.bind(field_name)
7680

7781
# Register a real table (a table with valid `model_name`) to
7882
# the metaclass `table` registry. After all the tables are
@@ -99,19 +103,77 @@ class ModelTable:
99103
def __init__(self, table_name: str, table_fields: dict[str, "BaseField"]) -> None:
100104
self.table_name = table_name
101105
self.table_fields = table_fields
106+
self.primary_key = next(
107+
(field_name for field_name, field in table_fields.items() if field.primary_key),
108+
None,
109+
)
110+
111+
def ddl(self) -> str:
112+
"""Build a simple CREATE TABLE statement for the schema."""
113+
columns = []
114+
for field_name, field in self.table_fields.items():
115+
sql = field.column_definition(field_name)
116+
if field.primary_key:
117+
sql = f"{sql} PRIMARY KEY"
118+
if field.default is not None and not field.primary_key:
119+
sql = f"{sql} DEFAULT {field.default!r}"
120+
if not field.nullable and not field.primary_key:
121+
sql = f"{sql} NOT NULL"
122+
columns.append(sql)
123+
return f"CREATE TABLE {self.table_name} ({', '.join(columns)});"
102124

103125

104126
class BaseField(ABC):
105-
"""Base field."""
127+
"""Base field.
128+
129+
A field carries its declared attribute name in `name`. It does not
130+
know that name until the metaclass calls `bind` at class creation
131+
time, which is the classic "late binding" metaclass trick.
132+
"""
133+
134+
name: str | None
135+
primary_key: bool
136+
nullable: bool
137+
default: Any
138+
139+
def __init__(self, *, primary_key: bool = False, nullable: bool = True, default: Any = None) -> None:
140+
self.name = None
141+
self.primary_key = primary_key
142+
self.nullable = nullable
143+
self.default = default
144+
145+
def bind(self, name: str) -> "BaseField":
146+
"""Bind this field to its declared attribute name at runtime."""
147+
self.name = name
148+
return self
149+
150+
def column_definition(self, field_name: str) -> str:
151+
"""Return the column SQL definition for this type."""
152+
raise NotImplementedError
106153

107154

108155
class CharField(BaseField):
109156
"""Character field."""
110157

158+
max_length: int
159+
160+
def __init__(self, *, max_length: int = 255, primary_key: bool = False, nullable: bool = True, default: Any = None) -> None:
161+
super().__init__(primary_key=primary_key, nullable=nullable, default=default)
162+
self.max_length = max_length
163+
164+
def column_definition(self, field_name: str) -> str:
165+
return f"{field_name} VARCHAR({self.max_length})"
166+
111167

112168
class IntegerField(BaseField):
113169
"""Integer field."""
114170

171+
def __init__(self, *, primary_key: bool = False, nullable: bool = True, default: Any = None) -> None:
172+
super().__init__(primary_key=primary_key, nullable=nullable, default=default)
173+
174+
def column_definition(self, field_name: str) -> str:
175+
return f"{field_name} INTEGER"
176+
115177

116178
class BaseModel(metaclass=ModelMeta):
117179
"""Base model.
@@ -125,15 +187,15 @@ class BaseModel(metaclass=ModelMeta):
125187
"""
126188

127189
__abstract__ = True # This is NOT a real table
128-
row_id = IntegerField()
190+
row_id = IntegerField(primary_key=True)
129191

130192

131193
class UserModel(BaseModel):
132194
"""User model."""
133195

134196
__table_name__ = "user_rocks" # This is a custom table name
135197
username = CharField()
136-
password = CharField()
198+
password = CharField(nullable=False, default="guest")
137199
age = CharField()
138200
sex = CharField()
139201

@@ -158,14 +220,49 @@ def main() -> None:
158220
assert "username" in UserModel.model_fields
159221
assert "address" in AddressModel.model_fields
160222

161-
# Real models are registered at runtime with `ModelMeta`
162-
assert UserModel.is_registered
163-
assert AddressModel.is_registered
223+
# Each field is late-bound to its declared attribute name at runtime
224+
assert UserModel.model_fields["username"].name == "username"
225+
assert UserModel.model_fields["password"].name == "password"
226+
assert AddressModel.model_fields["state"].name == "state"
227+
228+
# Inherited fields keep the name they were bound with in the base class
229+
assert UserModel.model_fields["row_id"].name == "row_id"
230+
assert AddressModel.model_fields["row_id"].name == "row_id"
231+
232+
# Primary keys are tracked on the field and the generated table metadata
233+
assert UserModel.model_fields["row_id"].primary_key is True
234+
assert AddressModel.model_fields["row_id"].primary_key is True
235+
assert UserModel.model_table is not None
236+
assert AddressModel.model_table is not None
237+
assert UserModel.model_table.primary_key == "row_id"
238+
assert AddressModel.model_table.primary_key == "row_id"
239+
240+
# A field built by hand and not yet bound has no name yet
241+
assert IntegerField().name is None
242+
243+
# Char fields can carry a max length, which is used in generated SQL
244+
username_field = UserModel.model_fields["username"]
245+
address_field = AddressModel.model_fields["address"]
246+
assert isinstance(username_field, CharField)
247+
assert isinstance(address_field, CharField)
248+
assert username_field.max_length == 255
249+
assert address_field.max_length == 255
164250

165251
# Real models have a `ModelTable` that can be used for DB setup
166252
assert isinstance(ModelMeta.tables[UserModel.model_name], ModelTable)
167253
assert isinstance(ModelMeta.tables[AddressModel.model_name], ModelTable)
168254

255+
# A table can generate a simple CREATE TABLE statement from its fields
256+
assert UserModel.model_table is not None
257+
assert AddressModel.model_table is not None
258+
assert UserModel.model_table.ddl() == (
259+
"CREATE TABLE user_rocks "
260+
"(row_id INTEGER PRIMARY KEY, username VARCHAR(255), password VARCHAR(255) DEFAULT 'guest' NOT NULL, age VARCHAR(255), sex VARCHAR(255));"
261+
)
262+
assert AddressModel.model_table.ddl() == (
263+
"CREATE TABLE address (row_id INTEGER PRIMARY KEY, user_id INTEGER, address VARCHAR(255), state VARCHAR(255), zip_code VARCHAR(255));"
264+
)
265+
169266
# Base model is given special treatment at runtime
170267
assert not BaseModel.is_registered
171268
assert BaseModel.model_name is None

0 commit comments

Comments
 (0)