stave.dev

Querying Entities

Three APIs, from narrowest to broadest: load by id, find by attribute, or drop straight to SQL against the pivoted view.

Load by ID

$u = \Zero\Entity\User::getInstance()->withId(42);
echo $u->email;

getInstance() returns a lightweight placeholder; withId() populates it from the database in a single query against the type's partition. If no row exists for that id, you get null.

Find by Attribute — where()

The primary lookup method on any entity class:

// Hand-coded class
$user = (new \Zero\Entity\User())->where('email', 'ada@example.com');

// GenericEntity (requires a prior define())
$prod = \Zero\Entity\GenericEntity::type('product')->where('sku', 'WDG-1');
Variable Return Type — Always Branch

where() returns null, a single Entity, or an array of Entity objects — depending on how many rows matched. This is the one behavioural quirk of the API. Always check the shape before using the result.

The canonical branching pattern:

$r = $model->where('email', $email);

if ($r === null) {
    // No match
} elseif (is_array($r)) {
    // Multiple matches — usually a bug for fields in $uniq
    foreach ($r as $entity) { /* ... */ }
} else {
    // Exactly one match — $r is an Entity
    echo $r->email;
}

Richer Queries — Hit the View

For anything beyond a single-attribute lookup — ranges, ordering, joins, pagination, aggregation — query the pivoted view directly. The view is recreated every time a new attribute is added, so it's always in sync with the schema.

SELECT * FROM product_view
 WHERE price > 10
 ORDER BY created_at DESC
 LIMIT 50;

Pattern used across the codebase for direct SQL:

$db = \Zero\Core\Database::getConnection();
$stmt = $db->prepare(
    "SELECT * FROM product_view WHERE status = ? LIMIT ?"
);
$stmt->execute(['active', 50]);
$rows = $stmt->fetchAll();

modules/Admin/model/Entity.php is the reference implementation — it reads the EAV tables directly for the admin UI's list/filter/paginate screen. Follow that pattern for anything non-trivial.

Writes and Updates

The class API is intentionally thin. Property assignment is an immediate REPLACE INTO:

$p = (new \Zero\Entity\Product())->withId(7);
$p->price  = '19.99';           // __set writes a REPLACE INTO immediately
$p->status = 'discontinued';    // each assignment is its own SQL statement

There is no save(), no dirty tracking, no transaction. Every assignment is a round trip. That is fine for admin screens and CRUD forms — it is not fine for a loop over 10,000 rows. Drop to raw SQL for bulk work.

Schema-on-Write

Assigning an attribute that does not yet exist in the schema will create it:

$p = (new \Zero\Entity\Product())->withId(7);
$p->gift_wrap = 'yes';   // attr 'gift_wrap' is not in the schema

// Framework:
//   1. INSERT a new schema row (id=0, attr=N, value='gift_wrap')
//   2. Rebuild product_view with the new column
//   3. REPLACE INTO for the instance's new attribute
Typos Become Columns

Schema-on-write is great when you're experimenting. It is dangerous in production — a typo in an attribute name becomes a permanent column, and the view rebuild is a side effect of an otherwise innocuous-looking assignment. Consider freezing the schema with explicit validation around user-supplied keys.

Unique Constraints

Declare uniqueness on the class:

class User extends Entity {
    public $uniq = ['email'];
}

Or via the uniq key of a define() config. Enforcement happens inside __set with a SELECT before the REPLACE — it is not a database index, so races are possible under concurrent writes. For truly critical uniqueness, add a partial unique index in SQL as well.