Builder
The builder pattern separates the construction of a complex object from its representation. That is, it allows you to produce different types and representations of an object using the same construction code, used especially when an object has many optional parameters.
TL;DR
Allows you to create a different flavor of an object while avoiding “constructor pollution” - that is, adding a large amount of required parameters to a constructor call. Instead, each part of the process can be added if needed.
https://refactoring.guru/design-patterns/builder
Problem
Suppose you have a program that builds a House for each client. The basic parts of a house - walls, doors, windows - are all common, so you implement a House interface. However, a client wants a house with a pool, so you subclass House into HouseWithPool.
If another client wants a house with a pool and a sauna, you might do HouseWithPoolAndSauna. What happens, then, if a client was a house with a sauna, and not a pool? The naive solution is to create yet another class, HouseWithSauna, but this quickly becomes cumbersome.
In this case, the solutions are either to have a subclass for every possible combination, or a single base class with a very large constructor:
class House:
def __init__(self, sauna, pool, door_number, patio, garden, etc, etc, etc):
...So on one side there is subclass pollution, on the other there is “telescoping constructor” pollution.
Solution
The Builder pattern solves this by extracting the object construction code out of its own class and to create new objects called builders, such as HouseBuilder. The builder object organizes the construction into a series of steps (such as buildWalls, buildDoor, addPool, etc.) such that each step can be called separately, but not all steps need to be called.
If the construction steps require different implementations - that is, the walls are made of wood, or of stone - different builder classes can be created that implement the same steps but in a different manner.
A common example of this pattern is a SQLQueryBuilder:
from typing import TypeVar, Generic, Type, Any
from sqlmodel import SQLModel, Session, select, func, and_, or_, col
T = TypeVar("T", bound=SQLModel)
class QueryBuilder(Generic[T]):
"""Generic class to build SQLModel queries"""
def __init__(self, session: Session, model: Type[T]):
self.session = session
self.model = model
self.query: select = select(model)
self.filters_applied = 0
def text_filter(self, field, value: list[str] | None, partial: bool = True):
# Always searches in lowercase
if value:
if isinstance(value, str):
value = [value]
value = [v.lower() for v in value]
if partial:
conditions = [
func.lower(col(field)).ilike(f"%{v}%") for v in value if v
]
self.query = self.query.where(or_(*conditions))
else:
self.query = self.query.where(func.lower(col(field)).in_(value))
self.filters_applied += 1
return self
def exact_match(self, field, value: Any = None):
if value is not None and isinstance(value, (str, int)):
self.query = self.query.where(col(field) == value)
self.filters_applied += 1
return self
def boolean_filter(self, field, value: bool | None = None):
return self.exact_match(field=field, value=value)
def range_filter(
self,
field,
min_value: int | None = None,
max_value: int | None = None,
exact_value: int | None = None,
):
if exact_value is not None:
self.query = self.query.where(col(field) == exact_value)
self.filters_applied += 1
else:
conditions = []
if min_value is not None:
conditions.append(col(field) >= min_value)
if max_value is not None:
conditions.append(col(field) <= max_value)
if conditions:
self.query = self.query.where(and_(*conditions))
self.filters_applied += 1
return self
def join(
self,
target_model: Type[SQLModel],
on_condition=None,
outer_join: bool = True,
full_join: bool = False,
):
if full_join:
self.query = self.query.outerjoin(target_model, on_condition, full=True)
elif outer_join:
self.query = self.query.outerjoin(target_model, on_condition)
else:
self.query = self.query.join(target_model, on_condition)
return self
def reset(self):
self.query = select(self.model)
self.filters_applied = 0
return self
def run(self, limit: int | None = None, distinct: bool = True):
if self.filters_applied == 0:
print("No filters applied. Query aborted")
return []
if limit:
self.query = self.query.limit(limit)
if distinct:
self.query = self.query.distinct()
# To avoid returning blank records
self.query = self.query.where(col(self.model.id).isnot(None))
return self.session.exec(self.query).all()