The Model Layer

9 Aufrufe Markdown

Models are where the database is allowed to be touched. They are also where a row stops being a row and becomes something the rest of the application can use.

Overview

A model belongs to a controller, is found by the same name, and is constructed for it. It exposes a small, predictable set of methods, and the controller that owns it does not know what a query looks like.

The reason for the boundary is not purity. It is that the same data is needed by the panel, the client area, the API and the scheduled command line. A query written into a page can only ever serve that page.

Structure

Location coremio/models/{surface}/{name}.php, matching the controller name exactly. Loaded by path during construction, not by the autoloader.
Class name Tried in this order: \WISECP\Models\{Surface}\{Name}, then {Name}Model, then a bare Model. No match leaves you with the plain base class and no methods of your own.
Connection $this->db, resolved on first read through __get(). A page that never queries never opens a connection.

Reference

The Base Class

coremio/classes/Models.php
public string $pfx;                      // table prefix, for raw SQL fragments
public ?Database $connection = null;
public static ?self $init = null;        // the last model constructed - WDB reads its connection

public function __get($name);            // 'db' returns the Database; anything else null
protected function lang_routes(string $langTable, int $ownerId): array;   // ['en' => 'slug', 'tr' => 'slug']
public function menu_list($type, $lang, $list = [], $parent = 0);
public function link_detector($link): string;

The connection behind $this->db is held in a function-static, so every model in the process shares one Database object. WDB is a static facade over that same object. Two entry points, one builder and one unfinished query state.

The Usual Methods

These are conventions rather than an interface, and following them means a reader who has seen one model has seen them all. The signatures below are the shape the newer models use.

the conventional surface of a model
public function list(bool $rCount = false, array $filters = [], array $orders = [], int $start = 0, int $end = -1): array|int;
public function get(int $id): array|false;
public function add(array $data = []): int;
public function update(int $id = 0, array $data = []): bool;
public function delete(int $id): bool;
$rCount true returns the row count as an int and skips the columns, ordering and paging. The table component calls the same method twice, once each way.
$filters Named conditions, read with ?? so every key is optional. word is the shared one, the free-text search. The rest are the model's own: services accepts status, user_id, product_id, server_id, cycle, duedate plus duedate_op, and so on.
$orders ['id' => 'DESC']: bare column name to direction. The model prefixes the table alias itself, so do not pass one. An empty array falls back to the model's default order.
$start, $end Offset and row count, passed straight to limit(). $end = -1 means no limit at all, which is the default, so ask for a page explicitly.
Missing record get() returns false, never an empty array, so the caller can tell "no such record" from "a record with nothing in it".

The Query Builder

Every builder method returns the connection, so calls chain and build() or save() closes the chain. The static facade forwards to the same object with one difference: its where() stops at four arguments, the instance takes a fifth.

coremio/classes/WDB.php, forwarding to coremio/classes/Database.php
public static function select($arg = '*');
public static function from($arg = '');
public static function join($type, $table, $where);                      // $type: "LEFT", "INNER", ...
public static function where($column, $mark = '', $value = '', $logical = '');
public static function group_by($arg = '');
public static function order_by($arg);
public static function limit($arg1, $arg2 = null);
public static function build($isthis = false);                           // runs a read, returns falsy on failure
public static function fetch_assoc($statement = false);                  // all rows
public static function getAssoc($statement = false);                     // one row
public static function getObject($statement = false);
public static function rowCounter($statement = false);                   // affected/returned row count
public static function insert($table, $data);                            // row count; the id comes from lastID()
public static function update($table = '', $data = []);                  // chain where(), then save()
public static function delete($arg = '', $arg2 = '');
public static function save($isthis = false);
public static function lastID();

// The instance carries one extra argument on where():
public function where($column = '', $mark = '', $value = '', $logical = '', $filter = '?'): self;
select() Opens a read with the column list as one string. Followed by the table, the joins, the conditions and the ordering.
from() The table, alias included: "knowledgebase AS t". The installation's table prefix is added for you.
join() Type, table and the join condition as a literal string. Values inside that string are not bound, so nothing user-supplied belongs there.
where() Column, comparison, value, and the operator that joins this condition to the next one. The value is bound. Omitting the fourth argument leaves an AND behind, which is added for you.
build() Executes the read. It is falsy on failure, which is why the house pattern is build() ? fetch : fallback rather than an unconditional fetch.
fetch_assoc() Every row as an associative array, or an empty array when there is no connection.
getAssoc() One row. Pair it with a limit(1) so the statement fetches what you intend to read.
insert() Writes a new row from an associative array and returns the affected row count. The new identifier comes from the call below, not from this one.
update() Starts a change and returns the connection; the conditions follow and save() commits it. An update without a condition rewrites the table.
lastID() The identifier produced by the last insert on this connection, as an int.

Translated Rows

Records shown to people keep their translations in a companion table named after the record's own. It holds one row per language, keyed by owner_id and lang. A read joins it for the active language and falls back to the record's own value. A partially translated installation still shows a value. The same table carries the per-language slug, which is what lang_routes() returns for locale switchers and alternate links.

Example

coremio/models/admin/widgets.php
namespace WISECP\Models\Admin;

use Language;
use Models;

class Widgets extends Models
{
    public function list(bool $rCount = false, array $filters = [], array $orders = [], int $start = 0, int $end = -1): array|int
    {
        $lang   = Language::selected();
        $search = $filters["word"] ?? '';
        if (!$orders || empty(array_key_first($orders))) $orders = ['id' => 'DESC'];

        $stmt = $this->db->select($rCount ? "t.id" : "t.id, COALESCE(tl.name, t.name) AS name, t.status")
            ->from("widgets AS t")
            ->join("LEFT", "widgets_lang AS tl", "tl.owner_id = t.id AND tl.lang = '" . $lang . "'");

        // Fourth argument: the operator that joins THIS condition to the next one.
        if ($search) $stmt->where("COALESCE(tl.name, t.name)", "LIKE", "%" . $search . "%", "&&");
        $stmt->where("t.status", "!=", "deleted");

        if ($rCount) return $stmt->build() ? $stmt->rowCounter() : 0;

        $order = array_key_first($orders);
        $stmt->order_by("t." . $order . " " . strtoupper($orders[$order]));
        if ($end != -1) $stmt->limit($start, $end);

        return $stmt->build() ? $stmt->fetch_assoc() : [];
    }

    public function get(int $id): array|false
    {
        if (!$id) return false;

        return $this->db->select("t.*, COALESCE(tl.name, t.name) AS name")
            ->from("widgets AS t")
            ->join("LEFT", "widgets_lang AS tl", "tl.owner_id = t.id AND tl.lang = '" . Language::selected() . "'")
            ->where("t.id", "=", $id)
            ->limit(1)
            ->build() ? $this->db->getAssoc() : false;
    }

    public function add(array $data = []): int
    {
        return $this->db->insert("widgets", $data) ? $this->db->lastID() : 0;
    }

    public function update(int $id = 0, array $data = []): bool
    {
        return (bool) $this->db->update("widgets", $data)->where("id", "=", $id)->save();
    }
}

The caller never sees a query, and the two shapes above are exactly what it branches on.

the reading side, in the controller
$total = $this->model->list(true, ['word' => $search]);            // int
$rows  = $this->model->list(false, ['word' => $search], ['id' => 'DESC'], 0, 25);

$widget = $this->model->get($id);
if ($widget === false) return $this->page_404();                   // false means "no such record"

Pitfalls

Bind values, never concatenate them

Conditions take their values as arguments so the driver binds them. A value pasted into the condition string, or into a join clause, is an injection waiting for the first unusual input.

The fourth argument of where() looks forward

It is not the operator that attaches this condition to the previous one. It attaches it to the next. Writing the operator on the wrong condition of an alternation quietly changes which rows come back, and nothing fails.

Select the columns you need

A getter that selects everything hands its callers whatever the table happens to contain. That includes columns added later that were never meant to leave the database. If such a value can reach a response, filter it at the boundary where it is written out.

Business rules are not model methods

A model answers what is stored. What should happen because of it belongs in a helper. The panel, the client area, the API and the scheduler can all reach it there.

War das hilfreich?

Vielen Dank für Ihre Rückmeldung!

Brauchen Sie weitere Hilfe?

Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.