Generated Schema
From one model the build writes six typed classes — the schema you extend, a query builder, an entity, a column enum, a route request and a status enum. This is what you actually write code against.
Where they live
The classes are written into a Schema folder beside the model, and are regenerated on every
build — so they are git-ignored and
never edited by hand:
src/Product/
├── Model/
│ └── ProductModel.php # what you write
├── Schema/ # what the build writes
│ ├── ProductSchema.php
│ ├── ProductQuery.php
│ ├── ProductEntity.php
│ ├── ProductColumn.php
│ ├── ProductRequest.php
│ └── ProductStatus.php
└── Product.php # your class, extending the schema
| Class | Is |
|---|---|
<Name>Schema | The base class you extend — every read and write goes through it. |
<Name>Query | A typed query builder, one property per column. |
<Name>Entity | A typed row. |
<Name>Column | An enum of the column names. |
<Name>Request | The typed route request. |
<Name>Status | The status enum, when the model declares states. |
The Schema
Your class extends the generated schema and becomes the only door to that table. The inherited methods cover the reads:
// src/Product/Product.php
class Product extends ProductSchema {
public static function getForCategory(int $categoryID): array {
$query = new ProductQuery();
$query->categoryID->equal($categoryID);
$query->name->orderByAsc();
return self::getEntityList($query);
}
}
| Method | Returns |
|---|---|
getEntity($query) | One entity — empty when nothing matched. |
getEntityList($query) | A list of entities. |
getEntityTotal($query) | How many rows match, for pagination. |
getEntityValue($query, $column) | A single column of a single row. |
getEntitySelect($query) | The rows as Select options. |
getEntitySearch($query) | The rows as Search results. |
getByID($id) / getBy(…) | Shortcuts for the common lookups. |
exists($id) / entityExists($query) | Whether a row is there. |
And the writes, which are protected — they are called from inside your class, so every
change to the table goes through a method you control:
class Product extends ProductSchema {
public static function create(ProductRequest $request): int {
return self::createEntity($request);
}
public static function edit(int $productID, ProductRequest $request): bool {
return self::editEntity($productID, $request);
}
public static function delete(int $productID): bool {
return self::deleteEntity($productID); // soft delete
}
}
| Method | Does |
|---|---|
createEntity(…) | Insert a row, returning its new id. |
editEntity($id, …) | Update a row. |
editEntityValue(…) / increaseEntity(…) | Change one column, or add to a number. |
replaceEntity(…) | Insert or update, whichever applies. |
deleteEntity($id) | Soft delete — the row is flagged, not removed. |
removeEntity($id) / removeAllEntities($query) | Delete for good. |
ensureUniqueData(…) / ensureEntityOrder(…) | Keep unique values and the position order consistent. |
validateRequest($request) | Run the Validate rules, returning a Result — hasError() and the errors to hand back. |
canDelete, and the create and edit
operations when it sets canCreate / canEdit.A parameter per field
The generated create and edit do not take an array of values — they take one optional, typed parameter per field. So a column that does not exist is a build error, not a row that silently fails to update, and a wrong type never reaches the database:
// Generated for the model — one argument per field
ProductSchema::createEntity(
?ProductRequest $request = null,
?string $name = null,
?float $price = null,
?int $categoryID = null,
?ProductStatus $status = null,
// …plus createdTime / createdUser when the model has them
): int
That makes writing a single field explicit and safe, with no request involved:
// Only the name is written
self::editEntity($productID, name: "New name");
// Several fields, each type-checked
self::createEntity(
name: $name,
price: $price,
categoryID: $categoryID,
status: ProductStatus::Active,
);
Writing from a Request
Passing a request fills the fields for you — but only the ones the model marked
#[Requested]. Any other column is left untouched, so a request
can never write a field you did not open to it:
// name and price are #[Requested] → taken from the request
// internalCode is not → left as it was
self::editEntity($productID, $request);
The two combine: an explicit argument always wins over the request, which is how you accept the user's fields and set the rest yourself in one call:
self::createEntity(
$request, // the #[Requested] fields
internalCode: $generatedCode, // not in the request — set here
status: ProductStatus::Draft,
);
Sometimes a field must be in the request — the route reads it, validates it, decides something with
it — but must not be written automatically. That is what canEdit: false is for: the field appears on
the request class, and is skipped when the request is applied:
#[Model(canCreate: true, canEdit: true)]
class ProductModel {
// ...
// Arrives in the request, but editEntity($id, $request) never writes it
#[Field]
#[Requested(canEdit: false)]
public int $stock;
}
// The route can read it and act on it
$stock = $request->stock;
// …and the write only happens when you ask for it
self::editEntity($productID, $request); // stock untouched
self::editEntity($productID, $request, stock: $stock); // written on purpose
Values the database computes
A field does not have to be given a plain value. Passing an Assign writes an expression
instead, so a counter is updated in place rather than read, changed and written back — which is both a query
shorter and safe against two requests racing:
use Framework\Database\Query\Assign;
class Product extends ProductSchema {
public static function addView(int $productID): bool {
// views = views + 1, in the database
return self::editEntity($productID, views: Assign::increase());
}
public static function takeStock(int $productID, int $amount): bool {
return self::editEntity($productID, stock: Assign::decrease($amount));
}
}
The same works for any of the Assign helpers —
Assign::equal() to copy another column, Assign::greatest() to keep the higher value,
Assign::uuid() for a generated code, or Assign::exp() for an expression of your own:
self::editEntity($productID,
code: Assign::uuid(),
highPrice: Assign::greatest(ProductColumn::Price),
total: Assign::exp("price * ?", [ $quantity ]),
);
An isEncrypt field is handled for you — the schema wraps the value in
Assign::encrypt() with the configured DB_KEY, so it is encrypted by the database on the
way in.
Rows with a position
When the model has an isPosition column, the order is kept
for you. Creating a row appends it at the end, deleting one closes the gap, and moving one shifts the rows in
between — so the positions stay contiguous without any bookkeeping:
class ProductImage extends ProductImageSchema {
public static function create(ProductImageRequest $request): int {
// Appended after the last image of this product
return self::createEntity($request);
}
public static function move(int $imageID, int $position): bool {
// The rows between the old and new position shift by one
return self::editEntity($imageID, position: $position);
}
public static function delete(int $imageID): bool {
// The rows after it move up to close the gap
return self::deleteEntity($imageID);
}
}
The order is kept within the parent — the isParent column — so each product numbers its
own images from one. Pass skipOrder: true to write the position exactly as given, and call
ensureEntityOrder() to renumber a set that drifted, after an import say.
The Query
The query is where the type safety pays off: it has one property per column, and each is a where object whose methods match the column's type. There are no column strings to misspell:
$query = new ProductQuery();
$query->name->like($search); // a StringWhere
$query->price->greaterThan(100); // a NumberWhere
$query->status->equal(ProductStatus::Active); // an EnumWhere
$query->isFeatured->isTrue(); // a BooleanWhere
$query->createdTime->inPeriod($period); // a DateWhere
$query->name->orderByAsc();
$query->paginate($page, $amount);
| Column type | Methods |
|---|---|
NumberWhere | equal, notEqual, greaterThan, lessThan, greaterOrEqual, lessOrEqual, in, notIn. |
StringWhere | equal, like, notLike, startsWith, endsWith, search, in. |
DateWhere | equal, the comparisons, inPeriod, isEmpty, isNotEmpty. |
EnumWhere | equal, equalName, in, like, isEmpty. |
BooleanWhere | isTrue, isFalse, isAny, equalTrueIf. |
Every comparison has an …If variant — equalIf, likeIf,
compareIf — that only applies when the value is not empty. That is what makes a filter screen read
straight down, with no conditionals:
$query = new ProductQuery();
$query->name->likeIf($request->name);
$query->categoryID->equalIf($request->categoryID);
$query->status->equal($request->status);
return self::getEntityList($query);
All the columns share the helpers of BaseWhere for ordering and grouping,
and the query itself carries the paging:
$query = new ProductQuery();
$query->name->orderByAsc(); // ORDER BY name ASC
$query->price->orderByDesc(); // then price DESC
$query->categoryID->groupBy(); // GROUP BY categoryID
$query->limit(10); // the first 10
$query->limit(20, 10); // 10 rows from the 20th
$query->paginate($page, $amount); // the page a listing asked for
Ordering is applied in the order you declare it, so the calls above sort by name and then by price. For a listing the usual shape is a filter, an order and a page — and the same query, without the paging, gives the total:
public static function getList(ProductRequest $request): array {
$query = new ProductQuery();
$query->name->likeIf($request->name);
$query->status->equal(ProductStatus::Active);
$query->createdTime->orderByDesc();
$total = self::getEntityTotal($query);
$query->paginate($request->page, $request->amount);
return [ self::getEntityList($query), $total ];
}
Grouped conditions
Conditions are joined with AND by default. When a filter needs an OR, or a mix of the
two, wrap the group in startOr() / endOr() and startAnd() /
endAnd() — they nest, and produce the parentheses for you:
$query = new ProductQuery();
// status = "Active" AND (name LIKE … OR code LIKE …)
$query->status->equal(ProductStatus::Active);
$query->startOr();
$query->name->like($search);
$query->code->like($search);
$query->endOr();
// (categoryID = 3 AND price < 100) OR (categoryID = 7 AND price < 50)
$query->startOr();
$query->startAnd();
$query->categoryID->equal(3);
$query->price->lessThan(100);
$query->endAnd();
$query->startAnd();
$query->categoryID->equal(7);
$query->price->lessThan(50);
$query->endAnd();
$query->endOr();
For a condition on another table, whereExists() and whereNotExists() take a second
query — the way to ask for products that have (or have not) a matching row elsewhere, without a join:
// Products that have at least one image
$images = new ProductImageQuery();
$images->productID->equalColumn("products.productID");
$query = new ProductQuery();
$query->whereExists($images);
The Entity
Rows come back as an entity with a typed property per field — the model's own columns, plus everything the relations, counts, expressions and sub-requests added:
$product = Product::getByID($productID);
$product->id; // int
$product->name; // string
$product->price; // float
$product->status; // ProductStatus
$product->createdTime; // Date
$product->categoryName; // brought by the #[Relation]
$product->tagCount; // by the #[Count]
$product->tags; // by the #[SubRequest]
$product->isEmpty(); // nothing was found
That is why a renamed column is a build error rather than a silent null: the property simply stops
existing. Entities are JsonSerializable, so a
Response can return one directly.
The Column
A backed enum of the column names. Wherever a method needs to be told which column to work with, it takes one of these instead of a string:
ProductColumn::Name;
ProductColumn::Price->toString(); // the column name
Reading a single value or a single column of many rows, without building an entity for it:
$query = new ProductQuery();
$query->productID->equal($productID);
// One value of one row
$name = self::getEntityValue($query, ProductColumn::Name);
// The same column across every matching row
$names = self::getEntityColumn($query, ProductColumn::Name);
Building an option list, where the columns say what becomes the id, the label and the extras:
$query = new ProductQuery();
$query->status->equal(ProductStatus::Active);
$query->name->orderByAsc();
$select = self::getEntitySelect(
$query,
nameColumn: ProductColumn::Name,
idColumn: ProductColumn::ProductID,
descColumn: ProductColumn::Code,
extraColumn: ProductColumn::Price,
useEmpty: true,
);
And mapping a request field onto one of your own columns, which is how the shared log listings are filtered per app:
ActionLog::getAll($request, [
"clientID" => LogActionColumn::ClientID,
]);
The Request
Built from the Requested fields, this is the class a
route type-hints instead of the generic
Request. The generated
Router builds it with fromRequest(), so the handler receives typed properties:
#[Route("/products/edit", Access::Admin)]
public static function edit(ProductRequest $request): Response {
$id = $request->id; // int
$name = $request->name; // string
$price = $request->price; // float
if ($request->isCreate) { /* … */ }
$result = Product::validateRequest($request);
if ($result->hasError()) {
return Response::error($result->errors);
}
return Response::success("PRODUCT_SAVED");
}
Besides the fields it carries the identifier and the isCreate / isEdit flags, so one
route can handle both cases — which is the pattern the framework's own screens use.
The Status
When the model declares states, the build writes an enum for them with everything the interface needs — the translated name, the color, and a ready-made select:
ProductStatus::Active;
ProductStatus::getName(ProductStatus::Paused); // the translated name
ProductStatus::getColor(ProductStatus::Paused); // "yellow"
ProductStatus::isValid($value);
ProductStatus::toNames($values);
ProductStatus::getSelect(); // the visible states, for a dropdown
ProductStatus::getFullSelect(); // including the hidden ones
Being a framework enum it also has the IsEnum helpers, and the
query takes the case directly — $query->status->equal(ProductStatus::Active) — so a status can
never be compared against a string that does not exist.