# urfat-orm A lightweight, zero-dependency PHP ORM with Laravel/Eloquent-inspired syntax. Built on top of PDO, it supports multiple named connections, soft deletes, timestamps, eager loading, and pivot table relationships. --- ## Installation ```bash composer install ``` No external dependencies. PHP 8.0+ required. --- ## Setup ```php require 'vendor/autoload.php'; use ORM\Connection; use ORM\Connections; // Using the factory helper $pdo = Connection::open('pgsql', 'dbname=mydb host=localhost', 'user', 'pass', 'myprefix_'); // Or register a raw PDO $pdo = new PDO("pgsql:dbname=mydb;host=localhost", 'user', 'pass'); $pdo->prefix = 'myprefix_'; Connections::addConnection($pdo); // 'default' Connections::addConnection($pdo, 'secondary'); // named connection ``` Supported drivers: `pgsql`, `mysql`, `sqlite`, `firebird`, `oracle`, `mssql`. --- ## Defining a Model ```php use ORM\Entity; class User extends Entity { const _tableName = 'users'; const _connectionName = 'default'; // optional, defaults to 'default' const _softdelete = true; // filters deleted_at IS NULL on all queries const _timestamps = true; // auto-sets created_at / updated_at on save const _ignore = ['password_confirm']; // excluded from INSERT / UPDATE const _idPolice = [ // optional custom ID strategy 'type' => 'nextval', 'min' => 500, 'max' => 9999, 'step' => 10, ]; protected function posts() { return $this->hasMany(Post::class, 'user_id'); } protected function country() { return $this->belongsTo(Country::class, 'country_id', 'id'); } protected function roles() { return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id'); } } ``` --- ## Features ### CRUD ```php // Create $user = new User(['name' => 'Alice', 'email' => 'alice@example.com']); $user->save(); // INSERT; $user->id is populated after // Read $user = User::findOne(42); $user = User::findOne(['email' => ['=', 'alice@example.com']]); $users = User::findAll(); $users = User::findMany(['active' => ['=', 1]], ['*'], ['LIMIT' => 10, 'OFFSET' => 0]); $n = User::count(['active' => ['=', 1]]); // Update $user->name = 'Bob'; $user->save(); // UPDATE because $user->id exists // Delete $user->delete(); // soft-delete if _softdelete=true, otherwise hard-delete $user->purge(); // always hard-delete $user->restore(); // clears deleted_at (soft-delete models only) ``` Criteria format: `['column' => ['operator', value]]` Supported operators: `=`, `<`, `>`, `<=`, `>=`, `IN`, `LIKE` ### Soft Delete When `_softdelete = true`, all `findAll`, `findOne`, `findMany`, and `count` calls automatically append `WHERE deleted_at IS NULL`. Pass `true` as the last argument to include trashed records: ```php $all = User::findAll(['*'], [], true); // includes soft-deleted $trashed = User::findOne(42, ['*'], true); ``` ### Timestamps When `_timestamps = true`, `created_at` and `updated_at` are set on construction and `updated_at` is refreshed on every `save()`. ### Relationships | Method | Description | |---|---| | `hasOne(Class, $remoteField)` | One child record keyed by local id | | `hasMany(Class, $remoteField)` | Many child records keyed by local id | | `belongsTo(Class, $localField, $remoteField)` | Parent record | | `belongsToMany(Class, $pivot, $localKey, $remoteKey)` | Many-to-many via pivot | | `belongsToManyExtended(...)` | Many-to-many via pivot; returns pivot rows with foreign object in `->childElement` | ```php // Lazy load (fires a query on access) $posts = $user->posts()->get(); $country = $user->country; // magic __get shorthand // Eager load on a Collection $users = User::findAll()->with(['posts', 'country']); // Eager load on a single Entity $user->with(['posts', 'country']); ``` ### Multiple Named Connections ```php Connections::addConnection($pdo1); // 'default' Connections::addConnection($pdo2, 'analytics'); // 'analytics' class Event extends Entity { const _connectionName = 'analytics'; } ``` ### Custom ID Strategy (`_idPolice`) When `type = 'nextval'` the ORM computes the next ID from `MAX(id)` before inserting, respecting `min`, `max`, and `step`. Useful for databases without auto-increment sequences. ### Raw Query Helpers (`DBInstance`) For queries that don't fit the Entity API: ```php use ORM\DBInstance; // Single record with prepared statement $row = DBInstance::getRecord('users', ['email' => ['=', $email]]); // Multiple records $rows = DBInstance::getRecords('users', ['active' => ['=', 1]], ['id', 'name'], ['LIMIT' => 50]); // Free SQL $rows = DBInstance::getRecordsSql("SELECT * FROM {users} WHERE created_at > ?", [$date]); // Insert and get last id $id = DBInstance::insertRecord('logs', ['action' => 'login', 'user_id' => 1]); // Update a single field DBInstance::setField('users', 'active', 0, $id); // Direct query DBInstance::execute("UPDATE {users} SET score = score + 1 WHERE id = ?", [$id]); ``` Table names always use `{tableName}` placeholder syntax so the connection prefix is applied automatically. ### SQL Tracing ```php $pdo->traceSQL = true; // prints every query to stdout ``` --- ## Known Bugs | # | Location | Description | |---|---|---| | 1 | `Collection::with()` | After the Relations namespace refactor, the `switch` class-name checks still use old names (`ORM\HasMany`, etc.) — all branches fall to `default`, which works but is inaccurate | | 2 | `Entity::findMany()`, `Entity::count()` | When no criteria are given and `_softdelete = true`, `AND deleted_at IS NULL` is appended without a preceding `WHERE`, producing invalid SQL | | 3 | `Entity` constructor / `update()` | Timestamp format uses `date('Y-m-d h:m:s')` — `h` is 12-hour clock and the second `m` is month; should be `date('Y-m-d H:i:s')` | | 4 | `Entity::__get()` | Returns `0` for any undefined property, silently masking typos | | 5 | `DBInstance::queryOne()` | When called with `$data`, it delegates to `queryPrepare()` which calls `fetchAll()` and returns an array, not a single object | | 6 | `DBInstance::setField()` | Uses PHP string interpolation `{$table}` instead of the ORM's `{tableName}` syntax, so the connection prefix is never applied | | 7 | `Entity::findMany()` | Values are interpolated directly into SQL (no prepared statements), making this method unsafe for user-supplied input | --- ## To Do - [ ] Fix `findMany()` and `count()` soft-delete `WHERE` clause when no other criteria exist - [ ] Fix timestamp format (`H:i:s`) - [ ] Fix `Collection::with()` class-name checks after Relations namespace refactor - [ ] Fix `DBInstance::setField()` table prefix placeholder - [ ] Fix `DBInstance::queryOne()` to actually return one record - [ ] Make `findMany()` use prepared statements - [ ] `Collection::with()` — use a bulk `IN (...)` query instead of one query per item (N+1 problem) - [ ] Relations cannot be iterated in Mustache / template engines (magic `__get` on `Entity` calls `->get()` but templates can't iterate the result transparently) - [ ] `__get()` should return `null` instead of `0` for undefined properties - [ ] Add formal test suite (PHPUnit) - [ ] Support `ORDER BY` in `findAll()` and `findMany()` - [ ] Support `whereNull` / `whereNotNull` criteria shorthand