vaibhavpandeyvpz / datum
A simple Active Record ORM for PHP built on top of databoss
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/vaibhavpandeyvpz/datum
Requires
- php: ^8.2
- psr/clock: ^1.0
- vaibhavpandeyvpz/databoss: ^2.0
Requires (Dev)
- phpunit/phpunit: ^10.0
- vaibhavpandeyvpz/samay: ^1.0
README
A simple Active Record ORM for PHP built on top of vaibhavpandeyvpz/databoss.
Features
- Active Record Pattern: Simple, intuitive model definitions
- Fluent Query Builder: Chain methods like
where(),sort(),limit(),offset(),get(),first() - Relationships: Support for
one(has one),many(has many),owner(belongs to), andowners(belongs to many) relationships - Attribute Casting: Automatic type conversion for DateTime, arrays, JSON, integers, floats, and booleans
- Automatic Timestamps: Automatically manages
created_atandupdated_attimestamps (enabled by default) - Lazy Connection Loading: Connection factory support for lazy database connection creation
- Built on Databoss: Leverages the powerful databoss filtering syntax
- Multi-Database Support: Works with MySQL, PostgreSQL, and SQLite
- Type-safe: Full PHP 8.2+ type declarations
- Well Tested: 90%+ code coverage with comprehensive test suite
Requirements
- PHP >= 8.2
- PDO extension
- One of:
ext-pdo_mysql,ext-pdo_pgsql, orext-pdo_sqlite(depending on your database)
Installation
composer require vaibhavpandeyvpz/datum
Or if you want to install the databoss dependency separately:
composer require vaibhavpandeyvpz/databoss ^2.0 composer require vaibhavpandeyvpz/datum
Quick Start
Setting Up the Connection
You can set the connection directly or use a connection factory for lazy connection creation using the connect() method:
Direct Connection
<?php use Databoss\Connection; use Datum\Model; // Create a databoss connection $connection = new Connection([ Connection::OPT_DATABASE => 'mydb', Connection::OPT_USERNAME => 'root', Connection::OPT_PASSWORD => 'password', ]); // Set the connection for all models Model::connect($connection);
Connection Factory (Lazy Loading)
<?php use Databoss\Connection; use Datum\Model; // Set a connection factory that will be called lazily when needed Model::connect(function () { return new Connection([ Connection::OPT_DATABASE => 'mydb', Connection::OPT_USERNAME => 'root', Connection::OPT_PASSWORD => 'password', ]); }); // The connection will only be created when you first use a model $user = User::find(1); // Connection is created here
Defining a Model
<?php use Datum\Model; class User extends Model { protected static ?string $table = 'users'; protected static string $primaryKey = 'id'; /** * Define attribute casts for automatic type conversion. * * @var array<string, string> */ protected static array $casts = [ 'age' => 'int', 'created_at' => 'datetime', 'metadata' => 'array', 'is_active' => 'bool', ]; }
Basic CRUD Operations
// Create $user = new User([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); $user->save(); // created_at and updated_at are automatically set // Read $user = User::find(1); $user = User::findOrFail(1); // Throws exception if not found // Update $user->name = 'Jane Doe'; $user->save(); // updated_at is automatically updated // Delete $user->delete(); // Get all $users = User::all();
Automatic Timestamps
Datum automatically manages created_at and updated_at timestamps by default. When you save a model:
- On Insert: Both
created_atandupdated_atare automatically set to the current timestamp - On Update: Only
updated_atis automatically updated
$user = new User(['name' => 'John', 'email' => 'john@example.com']); $user->save(); // created_at and updated_at are set automatically // Later... $user->name = 'Jane'; $user->save(); // updated_at is automatically updated, created_at remains unchanged
Disabling Timestamps:
If you want to disable automatic timestamps for a model, set the $timestamps property to false:
class User extends Model { protected static bool $timestamps = false; }
Custom Timestamp Column Names:
You can customize the timestamp column names:
class User extends Model { protected static string $createdAt = 'created_at'; protected static string $updatedAt = 'updated_at'; }
Manually Setting Timestamps:
You can still manually set timestamps, and they will be respected:
$user = new User([ 'name' => 'John', 'email' => 'john@example.com', 'created_at' => '2020-01-01 10:00:00', 'updated_at' => '2020-01-02 10:00:00', ]); $user->save(); // Your custom timestamps are preserved
Using PSR-20 Clock:
Datum uses PSR-20 ClockInterface for timestamp generation, allowing you to inject a custom clock implementation for testing or time manipulation:
use Psr\Clock\ClockInterface; use Datum\Model; // Set a custom clock Model::clock($yourClockInstance);
For testing, you can use vaibhavpandeyvpz/samay to control time:
use Samay\FrozenClock; use Datum\Model; // Freeze time at a specific moment $frozenTime = new \DateTimeImmutable('2024-01-15 10:30:00'); Model::clock(new FrozenClock($frozenTime)); $user = new User(['name' => 'Test']); $user->save(); // Will use the frozen time for timestamps
Attribute Casting
Datum supports automatic type casting for attributes. Define casts in your model's $casts property:
class User extends Model { protected static array $casts = [ 'age' => 'int', 'created_at' => 'datetime', 'metadata' => 'array', 'is_active' => 'bool', ]; }
Supported Cast Types:
intorinteger- Casts to integerfloatordouble- Casts to floatboolorboolean- Casts to boolean (stored as 0/1 in database)string- Casts to stringarrayorjson- Automatically JSON encodes/decodesdatetimeordate- Casts to/fromDateTimeobjects
Example:
// When loading from database $user = User::find(1); $user->created_at; // DateTime object $user->metadata; // Array (decoded from JSON) $user->age; // Integer // When setting values $user->created_at = new DateTime('2024-01-15'); $user->metadata = ['role' => 'admin']; $user->age = 25; $user->save(); // Values are automatically cast for storage
Querying
// Using where() with databoss filter syntax $users = User::where(['status' => 'active'])->get(); $user = User::where(['email' => 'john@example.com'])->first(); // Complex queries $users = User::where(['age{>}' => 18]) ->sort('created_at', 'DESC') ->limit(10) ->get(); // Count $count = User::where(['status' => 'active'])->count(); // Check existence $exists = User::where(['email' => 'john@example.com'])->exists();
Relationships
Has One
class User extends Model { public function profile() { return $this->one(Profile::class, 'user_id'); } } // Usage $user = User::find(1); $profile = $user->profile; // Automatically loaded
Has Many
class User extends Model { public function posts() { return $this->many(Post::class, 'user_id'); } } // Usage $user = User::find(1); $posts = $user->posts; // Array of Post models
Belongs To (Owner)
class Post extends Model { public function user() { return $this->owner(User::class, 'user_id'); } } // Usage $post = Post::find(1); $user = $post->user; // User model
Belongs To Many (Owners)
class User extends Model { public function roles() { return $this->owners( Role::class, 'user_roles', // pivot table 'user_id', // foreign pivot key 'role_id' // related pivot key ); } } // Usage $user = User::find(1); $roles = $user->roles; // Array of Role models
Advanced Filtering
Datum supports all databoss filter syntax:
// Comparison operators User::where(['age{>}' => 18])->get(); User::where(['price{<=}' => 100])->get(); User::where(['status{!}' => 'inactive'])->get(); // LIKE User::where(['name{~}' => '%John%'])->get(); // IN clause User::where(['category' => ['electronics', 'books']])->get(); // NULL handling User::where(['deleted_at' => null])->get(); User::where(['deleted_at{!}' => null])->get(); // Nested conditions User::where([ 'age{>}' => 18, 'OR' => [ 'status' => 'active', 'verified' => true, ], ])->get();
API Reference
Model Static Properties
protected static ?string $table- The table name (auto-inferred from class name if not set)protected static string $primaryKey- The primary key column name (default:'id')protected static array $casts- Attribute casting configurationprotected static bool $timestamps- Enable/disable automatic timestamp management (default:true)protected static string $createdAt- The name of the "created at" column (default:'created_at')protected static string $updatedAt- The name of the "updated at" column (default:'updated_at')
Model Static Clock Methods
Model::clock(ClockInterface $clock)- Set a PSR-20 clock instance for timestamp generation
Model Static Methods
Model::connect(ConnectionInterface|callable(): ConnectionInterface $connectionOrFactory)- Set the database connection directly or use a factory for lazy connection creationModel::query()- Create a new query builder instanceModel::where(array $conditions)- Create a query with WHERE conditionsModel::find(int|string $id)- Find a model by primary key (returnsnullif not found)Model::findOrFail(int|string $id)- Find a model or throwRuntimeExceptionif not foundModel::all()- Get all models from the tableModel::first()- Execute the query and return the first model
Model Instance Methods
$model->save()- Save the model to database (inserts if new, updates if exists)$model->delete()- Delete the model from database$model->exists()- Check if model exists in database$model->key()- Get the primary key value$model->toArray()- Convert model to array (with casts applied)$model->attribute(string $key)- Get an attribute value (with casting)$model->assign(string $key, mixed $value)- Set an attribute value (with casting)$model->attributes()- Get all attributes as array (raw, without casting)$model->freshTimestamp()- Get a fresh timestamp string (used internally for automatic timestamps)
Builder Methods
where(array $conditions)- Add WHERE conditions (supports databoss filter syntax)sort(string $column, string $direction = 'ASC')- Add ORDER BY clauselimit(int $limit)- Set LIMIT clauseoffset(int $offset)- Set OFFSET clauseget()- Execute and return all results (returnsarray|false)first()- Execute and return first result (returnsobject|array|false)count()- Count matching records (returnsint|false)exists()- Check if any records exist (returnsbool)recreate()- Get a fresh instance of the query builder
Relationship Methods
one(string $related, string $foreignKey, string $localKey = 'id')- Define has one relationshipmany(string $related, string $foreignKey, string $localKey = 'id')- Define has many relationshipowner(string $related, string $foreignKey, string $ownerKey = 'id')- Define belongs to relationship (this model is owned by another)owners(string $related, string $pivotTable, string $foreignPivotKey, string $relatedPivotKey, string $parentKey = 'id', string $relatedKey = 'id')- Define belongs to many relationship (this model is owned by many)
Property Access
Models support magic property access for attributes and relationships:
$user = User::find(1); $user->name; // Attribute access (with casting) $user->profile; // Relationship access (lazy loaded) $user->name = 'New'; // Attribute assignment (with casting) isset($user->name); // Check if attribute exists
Examples
Complete Example
<?php use Databoss\Connection; use Datum\Model; // Setup connection $connection = new Connection([ Connection::OPT_DATABASE => 'mydb', Connection::OPT_USERNAME => 'root', Connection::OPT_PASSWORD => 'password', ]); Model::connect($connection); // Define model class User extends Model { protected static ?string $table = 'users'; protected static array $casts = [ 'age' => 'int', 'created_at' => 'datetime', 'metadata' => 'array', ]; public function profile() { return $this->one(Profile::class, 'user_id'); } public function posts() { return $this->many(Post::class, 'user_id'); } } // Create $user = new User([ 'name' => 'John Doe', 'email' => 'john@example.com', 'age' => 30, 'created_at' => new DateTime(), 'metadata' => ['role' => 'admin'], ]); $user->save(); // Query $users = User::where(['age{>}' => 25]) ->sort('created_at', 'DESC') ->limit(10) ->get(); // Relationships $profile = $user->profile; $posts = $user->posts;
Testing
The project includes Docker Compose configuration for running tests:
# Start database containers (MySQL and PostgreSQL) docker compose up -d # Wait for databases to be ready, then run tests ./vendor/bin/phpunit # Run tests with coverage XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-text # Stop database containers docker compose down
Tests run against MySQL, PostgreSQL, and SQLite to ensure compatibility across all supported databases.
The test suite includes:
- 163+ tests
- 426+ assertions
- 90%+ code coverage
- Tests for all CRUD operations
- Tests for all relationship types
- Tests for attribute casting
- Tests for query builder methods
- Edge case and error handling tests
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
MIT License - see LICENSE file for details.