Framework
Database

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
ClassIs
<Name>SchemaThe base class you extend — every read and write goes through it.
<Name>QueryA typed query builder, one property per column.
<Name>EntityA typed row.
<Name>ColumnAn enum of the column names.
<Name>RequestThe typed route request.
<Name>StatusThe 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);
    }
}
MethodReturns
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
    }
}
MethodDoes
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 ResulthasError() and the errors to hand back.
The soft delete only exists when the model sets 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 typeMethods
NumberWhereequal, notEqual, greaterThan, lessThan, greaterOrEqual, lessOrEqual, in, notIn.
StringWhereequal, like, notLike, startsWith, endsWith, search, in.
DateWhereequal, the comparisons, inPeriod, isEmpty, isNotEmpty.
EnumWhereequal, equalName, in, like, isEmpty.
BooleanWhereisTrue, 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.