diff --git a/README.md b/README.md index bb83bef..319eb0a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,215 @@ -# ORM -aasasf -ddd +# 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