stave.dev

Storage Model

Two MySQL tables hold every entity type you will ever define. A per-type pivot view gives normal SQL its row-per-record shape back.

The Two Tables

The Entity Framework writes into just two tables, regardless of how many types you define:

entity_type

id (INT) label (VARCHAR)

One row per type. label is always lowercase — the class's short name folded to lower case (Useruser).

entity

type (INT) id (INT) attr (INT) value (VARCHAR(255))

Primary key across all four columns. Partitioned by type — each new type gets its own partition via ALTER TABLE.

{name}_view

id, col_0, col_1, …

Auto-generated CREATE VIEW that pivots the EAV rows using MAX(CASE WHEN attr = N). This is what your SELECT statements should actually hit.

Schema Rows vs. Instance Rows

The entity table overloads id to carry two kinds of data:

Row Type Where id is attr means value means
Schema id = 0 0-based column index Column name (email, created_at…)
Record id > 0 Column index (matches schema) The actual value for that attribute

So a type's schema is literally "the set of rows with id = 0." Adding an attribute is just another insert.

From EAV Rows to a Pivoted View

Suppose you call:

new \Zero\Entity\User(['email' => 'a@x.com', 'name' => 'Ada']);
new \Zero\Entity\User(['email' => 'b@x.com', 'name' => 'Bob']);

Behind the scenes, the rows look like this:

entity (id=0, schema)
typeidattrvalue
100email
101name
entity (id>0, records)
typeidattrvalue
110a@x.com
111Ada
120b@x.com
121Bob
user_view (auto-generated)
idemailname
1a@x.comAda
2b@x.comBob

The pivot view is regenerated every time you add an attribute to the schema — which means the normal shape of your table "just works" in SELECT, joins, and aggregates:

SELECT * FROM user_view WHERE email = 'a@x.com';
SELECT COUNT(*) FROM user_view;
SELECT u.name, COUNT(e.id) AS event_count
  FROM user_view u
  LEFT JOIN event_view e ON e.user_id = u.id
 GROUP BY u.id;
Query the view. Write through the class.

Rule of thumb: read from {name}_view (joins, pagination, aggregation are easy), write through the Entity class (schema changes and partition management are handled for you).

Partitioning

Each new entity type adds one MySQL partition to the entity table:

ALTER TABLE entity REORGANIZE PARTITION p_rest
  INTO (PARTITION p_user VALUES IN (1), PARTITION p_rest VALUES IN (MAXVALUE));

Partitioning keeps queries for one type isolated from all the others on disk, which is what makes this scheme fast enough to be practical. MySQL's default cap is 1024 partitions per table, so a codebase that defines hundreds of transient types will hit the ceiling — compact unused types rather than accumulating.

Initialization

Just including entity/Entity.php connects to the database:

require_once ZERO_ROOT . '/entity/Entity.php';    // calls Database::getConnection() and Entity::init(...)
require_once ZERO_ROOT . '/entity/User.php';
"You must call 'init' first"

If you see this error, something referenced the Zero\Entity namespace without first require_once’ing entity/Entity.php. Entity classes are not autoloaded — include them explicitly. See Footguns for the canonical fallback pattern.