Initial commit

This commit is contained in:
Funky Waddle 2025-12-10 07:01:07 -06:00
parent 80a640c241
commit ae80d9bde1
40 changed files with 4056 additions and 39 deletions

63
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,63 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: [ '8.1', '8.2', '8.3' ]
services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: pairity
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -proot"
--health-interval 10s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: pdo, pdo_mysql, pdo_sqlite
coverage: none
- name: Install dependencies
run: |
composer install --no-interaction --prefer-dist
- name: Prepare MySQL
run: |
sudo apt-get update
# wait for mysql to be healthy
for i in {1..30}; do
if mysqladmin ping -h 127.0.0.1 -proot --silent; then
break
fi
sleep 2
done
mysql -h 127.0.0.1 -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS pairity;'
- name: Run tests
env:
MYSQL_HOST: 127.0.0.1
MYSQL_PORT: 3306
MYSQL_DB: pairity
MYSQL_USER: root
MYSQL_PASS: root
run: |
vendor/bin/phpunit --colors=always

39
.gitignore vendored
View file

@ -10,31 +10,7 @@ composer.phar
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
.idea/
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
@ -67,21 +43,8 @@ out/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser

464
README.md
View file

@ -1,3 +1,465 @@
# Pairity
A partitioned model ORM. Handles DAO and DTO objects
A partitionedmodel PHP ORM (DTO/DAO) with Query Builder, relations, raw SQL helpers, and a portable migrations + schema builder. Namespace: `Pairity\`. Package: `getphred/pairity`.
## Contributing
This is an early foundation. Contributions, discussions, and design proposals are welcome. Please open an issue to coordinate larger features.
## License
MIT
## Installation
- Requirements: PHP >= 8.1, PDO extension for your database(s)
- Install via Composer:
```
composer require getphred/pairity
```
After install, you can use the CLI at `vendor/bin/pairity`.
## Quick start
Minimal example with SQLite (file db.sqlite) and a simple `users` DAO/DTO.
```php
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
// 1) Connect
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/db.sqlite',
]);
// 2) Ensure table exists (demo)
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
name TEXT NULL,
status TEXT NULL
)');
// 3) Define DTO + DAO
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
}
// 4) CRUD
$dao = new UserDao($conn);
$created = $dao->insert(['email' => 'a@b.com', 'name' => 'Alice', 'status' => 'active']);
$one = $dao->findById($created->toArray()['id']);
$many = $dao->findAllBy(['status' => 'active']);
$dao->update($created->toArray()['id'], ['name' => 'Alice Updated']);
$dao->deleteById($created->toArray()['id']);
```
For MySQL, configure:
```php
$conn = ConnectionManager::make([
'driver' => 'mysql',
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'app',
'username' => 'root',
'password' => 'secret',
'charset' => 'utf8mb4',
]);
```
## Concepts
- DTO (Data Transfer Object): a lightweight data bag. Extend `Pairity\Model\AbstractDto`. Convert to arrays via `toArray(bool $deep = true)`.
- DAO (Data Access Object): tablefocused persistence and relations. Extend `Pairity\Model\AbstractDao` and implement:
- `getTable(): string`
- `dtoClass(): string` (class-string of your DTO)
- Optional: `schema()` for casts, timestamps, soft deletes
- Optional: `relations()` for `hasOne`/`hasMany`/`belongsTo`
- Relations are DAOcentric: call `with([...])` to eager load; `load()`/`loadMany()` for lazy.
- Field projection via `fields('id', 'name', 'posts.title')` with dotnotation for related selects.
- Raw SQL: use `ConnectionInterface::query`, `execute`, `transaction`, `lastInsertId`.
- Query Builder: a simple builder (`Pairity\Query\QueryBuilder`) exists for adhoc SQL composition.
## Dynamic DAO methods
AbstractDao supports dynamic helpers, mapped to column names (Studly/camel to snake_case):
- `findOneBy<Column>($value): ?DTO`
- `findAllBy<Column>($value): DTO[]`
- `updateBy<Column>($value, array $data): int` (returns affected rows)
- `deleteBy<Column>($value): int`
Examples:
```php
$user = $dao->findOneByEmail('a@b.com');
$actives = $dao->findAllByStatus('active');
$dao->updateByEmail('a@b.com', ['name' => 'New Name']);
$dao->deleteByEmail('gone@b.com');
```
## Selecting fields
- Default projection is `SELECT *`.
- Use `fields(...$fields)` to limit columns. You can include relation fields using dotnotation:
```php
$users = (new UserDao($conn))
->fields('id', 'name', 'posts.title')
->with(['posts'])
->findAllBy(['status' => 'active']);
```
Notes:
- `fields()` affects only the next `find*` call and then resets.
- Relation field selections are passed to the related DAO when eager loading.
## Supported databases
- MySQL/MariaDB
- SQLite
- PostgreSQL
- SQL Server
- Oracle
NoSQL: a minimal inmemory MongoDB stub is included (`Pairity\NoSql\Mongo\MongoConnectionInterface` and `MongoConnection`) for experimentation without external deps.
## Raw SQL
Use the `ConnectionInterface` behind your DAO for direct SQL.
```php
use Pairity\Contracts\ConnectionInterface;
// Get connection from DAO
$conn = $dao->getConnection();
// SELECT
$rows = $conn->query('SELECT id, email FROM users WHERE status = :s', ['s' => 'active']);
// INSERT/UPDATE/DELETE
$affected = $conn->execute('UPDATE users SET status = :s WHERE id = :id', ['s' => 'inactive', 'id' => 10]);
// Transaction
$conn->transaction(function (ConnectionInterface $db) {
$db->execute('INSERT INTO logs(message) VALUES(:m)', ['m' => 'started']);
// ...
});
```
## Relations (DAOcentric MVP)
Declare relations in your DAO by overriding `relations()` and use `with()` for eager loading or `load()`/`loadMany()` for lazy loading.
Example: `User hasMany Posts`, `Post belongsTo User`
```php
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
class UserDto extends AbstractDto {}
class PostDto extends AbstractDto {}
class UserDao extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function relations(): array {
return [
'posts' => [
'type' => 'hasMany',
'dao' => PostDao::class,
'dto' => PostDto::class,
'foreignKey' => 'user_id', // on posts
'localKey' => 'id', // on users
],
];
}
}
class PostDao extends AbstractDao {
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return PostDto::class; }
protected function relations(): array {
return [
'user' => [
'type' => 'belongsTo',
'dao' => UserDao::class,
'dto' => UserDto::class,
'foreignKey' => 'user_id', // on posts
'otherKey' => 'id', // on users
],
];
}
}
$users = (new UserDao($conn))
->fields('id', 'name', 'posts.title')
->with(['posts'])
->findAllBy(['status' => 'active']);
// Lazy load a relation later
$postDao = new PostDao($conn);
$post = $postDao->findOneBy(['id' => 10]);
$postDao->load($post, 'user');
```
Notes:
- Eager loader batches queries using `IN (...)` lookups under the hood.
- Loaded relations are attached onto the DTO under the relation name (e.g., `$user->posts`).
- `hasOne` is supported like `hasMany` but attaches a single DTO instead of a list.
## Model metadata & schema mapping (MVP)
Define schema metadata on your DAO by overriding `schema()`. The schema enables:
- Column casts (storage <-> PHP): `int`, `float`, `bool`, `string`, `datetime`, `json`
- Timestamps automation (`createdAt`, `updatedAt` filled automatically)
- Soft deletes (update `deletedAt` instead of hard delete, with query scopes)
Example:
```php
use Pairity\Model\AbstractDao;
class UserDao extends AbstractDao
{
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
// Optional: declare primary key, casts, timestamps, soft deletes
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'email' => ['cast' => 'string'],
'name' => ['cast' => 'string'],
'status' => ['cast' => 'string'],
// if present in your table
'data' => ['cast' => 'json'],
'created_at' => ['cast' => 'datetime'],
'updated_at' => ['cast' => 'datetime'],
'deleted_at' => ['cast' => 'datetime'],
],
'timestamps' => [
'createdAt' => 'created_at',
'updatedAt' => 'updated_at',
],
'softDeletes' => [
'enabled' => true,
'deletedAt' => 'deleted_at',
],
];
}
}
// Usage (defaults to SELECT * unless you call fields())
$users = (new UserDao($conn))
->findAllBy(['status' => 'active']);
// Soft delete vs hard delete
(new UserDao($conn))->deleteById(10); // if softDeletes enabled => sets deleted_at timestamp
// Query scopes for soft deletes
$all = (new UserDao($conn))->withTrashed()->findAllBy(); // include soft-deleted rows
$trashedOnly = (new UserDao($conn))->onlyTrashed()->findAllBy(); // only soft-deleted
// Casting on hydration and storage
$user = (new UserDao($conn))->findById(1); // date columns become DateTimeImmutable; json becomes array
$created = (new UserDao($conn))->insert([
'email' => 'a@b.com',
'name' => 'Alice',
'status' => 'active',
'data' => ['tags' => ['a','b']], // stored as JSON automatically
]);
```
### Timestamps & Soft Deletes
- Configure in your DAO `schema()` using keys:
- `timestamps``['createdAt' => 'created_at', 'updatedAt' => 'updated_at']`
- `softDeletes``['enabled' => true, 'deletedAt' => 'deleted_at']`
- Behavior:
- On `insert()`, both `created_at` and `updated_at` are auto-filled (UTC `Y-m-d H:i:s`).
- On `update()` and `updateBy()`, `updated_at` is auto-updated.
- On `deleteById()` / `deleteBy()`, if soft deletes are enabled, rows are marked by setting `deleted_at` instead of being physically removed.
- Default queries exclude soft-deleted rows. Use scopes `withTrashed()` and `onlyTrashed()` to modify visibility.
- Helpers:
- `restoreById($id)` / `restoreBy($criteria)` — set `deleted_at` to NULL.
- `forceDeleteById($id)` / `forceDeleteBy($criteria)` — permanently delete.
- `touch($id)` — update only the `updated_at` column.
Example:
```php
$dao = new UserDao($conn);
$user = $dao->insert(['email' => 'x@y.com']); // created_at/updated_at filled
$dao->update($user->id, ['name' => 'Updated']); // updated_at bumped
$dao->deleteById($user->id); // soft delete
$also = $dao->withTrashed()->findById($user->id); // visible with trashed
$dao->restoreById($user->id); // restore
$dao->forceDeleteById($user->id); // permanent
```
## Migrations & Schema Builder
Pairity ships a lightweight migrations runner and a portable schema builder focused on MySQL and SQLite for v1. You can declare migrations as PHP classes implementing `Pairity\Migrations\MigrationInterface` and build tables with a fluent `Schema` builder.
Supported:
- Table operations: `create`, `drop`, `dropIfExists`, `table(...)` (ALTER)
- Columns: `increments`, `bigIncrements`, `integer`, `bigInteger`, `string(varchar)`, `text`, `boolean`, `json`, `datetime`, `decimal(precision, scale)`, `timestamps()`
- Indexes: `primary([...])`, `unique([...], ?name)`, `index([...], ?name)`
- ALTER (MVP): `add column` (all drivers), `drop column` (MySQL, Postgres, SQL Server; SQLite 3.35+), `rename column` (MySQL 8+/Postgres/SQL Server; SQLite 3.25+), `add/drop index/unique`, `rename table`
- Drivers: MySQL/MariaDB (default), SQLite (auto-detected), PostgreSQL (pgsql), SQL Server (sqlsrv), Oracle (oci)
Example migration (see `examples/migrations/CreateUsersTable.php`):
```php
use Pairity\Migrations\MigrationInterface;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
return new class implements MigrationInterface {
public function up(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->create('users', function (Blueprint $t) {
$t->increments('id');
$t->string('email', 190);
$t->unique(['email']);
$t->string('name', 255)->nullable();
$t->string('status', 50)->nullable();
$t->timestamps();
});
}
public function down(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->dropIfExists('users');
}
};
```
Running migrations (SQLite example):
```php
<?php
require __DIR__.'/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Migrations\Migrator;
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
$createUsers = require __DIR__ . '/migrations/CreateUsersTable.php';
$migrator = new Migrator($conn);
$migrator->setRegistry(['CreateUsersTable' => $createUsers]);
$applied = $migrator->migrate(['CreateUsersTable' => $createUsers]);
echo 'Applied: ' . json_encode($applied) . PHP_EOL;
```
Notes:
- The migrations runner tracks applied migrations in a `migrations` table with batches.
- For rollback, keep a registry of name => instance in the same process or use class names that can be autoloaded.
- SQLite has limitations around `ALTER TABLE` operations; this MVP emits native `ALTER TABLE` for supported versions (ADD COLUMN always; RENAME/DROP COLUMN require modern SQLite). For legacy versions, operations may fail; rebuild strategies can be added later.
- Pairity includes a best-effort table rebuild fallback for legacy SQLite: when `DROP COLUMN`/`RENAME COLUMN` is unsupported, it recreates the table and copies data. Complex constraints/triggers may not be preserved.
### CLI
Pairity ships a tiny CLI for migrations. After `composer install`, the binary is available as `vendor/bin/pairity` or as a project bin if installed as a dependency.
Usage:
```
pairity migrate [--path=DIR] [--config=FILE]
pairity rollback [--steps=N] [--config=FILE]
pairity status [--path=DIR] [--config=FILE]
pairity reset [--config=FILE]
pairity make:migration Name [--path=DIR]
```
Options and environment:
- If `--config=FILE` is provided, it must be a PHP file returning the ConnectionManager config array.
- Otherwise, the CLI reads environment variables:
- `DB_DRIVER` (mysql|mariadb|pgsql|postgres|postgresql|sqlite|sqlsrv)
- `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`, `DB_CHARSET` (MySQL)
- `DB_PATH` (SQLite path)
- If nothing is provided, defaults to a SQLite file at `db.sqlite` in project root.
Migration discovery:
- The CLI looks for migrations in `./database/migrations`, then `project/database/migrations`, then `examples/migrations`.
- Each PHP file should `return` a `MigrationInterface` instance (see examples). Files are applied in filename order.
Example ALTER migration (users add bio):
```php
return new class implements MigrationInterface {
public function up(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->table('users', function (Blueprint $t) {
$t->string('bio', 500)->nullable();
$t->index(['status'], 'users_status_index');
});
}
public function down(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->table('users', function (Blueprint $t) {
$t->dropIndex('users_status_index');
$t->dropColumn('bio');
});
}
};
```
Notes:
- Schema is optional; if omitted, DAOs behave as before (no casting, no timestamps/soft deletes).
- Timestamps use UTC and the format `Y-m-d H:i:s` for portability.
- Default `SELECT` is `*`. To limit columns, use `fields()`; it always takes precedence.
## DTO toArray (deep vs shallow)
DTOs implement `toArray(bool $deep = true)`.
- When `$deep` is true (default): the DTO is converted to an array and any related DTOs (including arrays of DTOs) are recursively converted.
- When `$deep` is false: only the top-level attributes are converted; related DTOs remain as objects.
Example:
```php
$users = (new UserDao($conn))
->with(['posts'])
->findAllBy(['status' => 'active']);
$deep = array_map(fn($u) => $u->toArray(), $users); // deep (default)
$shallow = array_map(fn($u) => $u->toArray(false), $users); // shallow
```
## Roadmap
- Relations enhancements:
- Nested eager loading (e.g., `posts.comments`)
- `belongsToMany` (pivot tables)
- Optional joinbased eager loading strategy
- Unit of Work & Identity Map
- Schema builder: broader ALTER coverage and more dialect nuances; better SQLite rebuild for complex constraints
- CLI: additional commands and qualityoflife improvements
- Testing matrix and examples for more drivers
- Caching layer and query logging hooks
- Production NoSQL adapters (MongoDB driver integration)

253
bin/pairity Normal file
View file

@ -0,0 +1,253 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Migrations\Migrator;
use Pairity\Migrations\MigrationLoader;
// Simple CLI utility for migrations
function stderr(string $msg): void { fwrite(STDERR, $msg . PHP_EOL); }
function stdout(string $msg): void { fwrite(STDOUT, $msg . PHP_EOL); }
function parseArgs(array $argv): array {
$args = ['_cmd' => $argv[1] ?? 'help'];
for ($i = 2; $i < count($argv); $i++) {
$a = $argv[$i];
if (str_starts_with($a, '--')) {
$eq = strpos($a, '=');
if ($eq !== false) {
$key = substr($a, 2, $eq - 2);
$val = substr($a, $eq + 1);
$args[$key] = $val;
} else {
$key = substr($a, 2);
$args[$key] = true;
}
} else {
$args[] = $a;
}
}
return $args;
}
function loadConfig(array $args): array {
// Priority: --config=path.php (must return array), else env vars, else SQLite db.sqlite in project root
if (isset($args['config'])) {
$path = (string)$args['config'];
if (!is_file($path)) {
throw new InvalidArgumentException("Config file not found: {$path}");
}
$cfg = require $path;
if (!is_array($cfg)) {
throw new InvalidArgumentException('Config file must return an array');
}
return $cfg;
}
$driver = getenv('DB_DRIVER') ?: null;
if ($driver) {
$driver = strtolower($driver);
$cfg = ['driver' => $driver];
switch ($driver) {
case 'mysql':
case 'mariadb':
$cfg += [
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'port' => (int)(getenv('DB_PORT') ?: 3306),
'database' => getenv('DB_DATABASE') ?: '',
'username' => getenv('DB_USERNAME') ?: null,
'password' => getenv('DB_PASSWORD') ?: null,
'charset' => getenv('DB_CHARSET') ?: 'utf8mb4',
];
break;
case 'pgsql':
case 'postgres':
case 'postgresql':
$cfg += [
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'port' => (int)(getenv('DB_PORT') ?: 5432),
'database' => getenv('DB_DATABASE') ?: '',
'username' => getenv('DB_USERNAME') ?: null,
'password' => getenv('DB_PASSWORD') ?: null,
];
break;
case 'sqlsrv':
case 'mssql':
$cfg += [
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'port' => (int)(getenv('DB_PORT') ?: 1433),
'database' => getenv('DB_DATABASE') ?: '',
'username' => getenv('DB_USERNAME') ?: null,
'password' => getenv('DB_PASSWORD') ?: null,
];
break;
case 'sqlite':
$cfg += [
'path' => getenv('DB_PATH') ?: (__DIR__ . '/../db.sqlite'),
];
break;
default:
// fall back later
break;
}
return $cfg;
}
// Default: SQLite file in project root
return [
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
];
}
function migrationsDir(array $args): string {
if (isset($args['path'])) return (string)$args['path'];
$candidates = [getcwd() . '/database/migrations', __DIR__ . '/../database/migrations', __DIR__ . '/../examples/migrations'];
foreach ($candidates as $dir) {
if (is_dir($dir)) return $dir;
}
return __DIR__ . '/../examples/migrations';
}
function ensureDir(string $dir): void {
if (!is_dir($dir)) {
if (!mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new RuntimeException('Failed to create directory: ' . $dir);
}
}
}
function cmd_help(): void
{
$help = <<<TXT
Pairity CLI
Usage:
pairity migrate [--path=DIR] [--config=FILE]
pairity rollback [--steps=N] [--config=FILE]
pairity status [--path=DIR] [--config=FILE]
pairity reset [--config=FILE]
pairity make:migration Name [--path=DIR]
Environment:
DB_DRIVER, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PATH (for sqlite)
If --config is provided, it must be a PHP file returning the ConnectionManager config array.
TXT;
stdout($help);
}
$args = parseArgs($argv);
$cmd = $args['_cmd'] ?? 'help';
try {
switch ($cmd) {
case 'migrate':
$config = loadConfig($args);
$conn = ConnectionManager::make($config);
$dir = migrationsDir($args);
$migrations = MigrationLoader::fromDirectory($dir);
if (!$migrations) {
stdout('No migrations found in ' . $dir);
exit(0);
}
$migrator = new Migrator($conn);
$migrator->setRegistry($migrations);
$applied = $migrator->migrate($migrations);
stdout('Applied: ' . json_encode($applied));
break;
case 'rollback':
$config = loadConfig($args);
$conn = ConnectionManager::make($config);
$dir = migrationsDir($args);
$migrations = MigrationLoader::fromDirectory($dir);
$migrator = new Migrator($conn);
$migrator->setRegistry($migrations);
$steps = isset($args['steps']) ? max(1, (int)$args['steps']) : 1;
$rolled = $migrator->rollback($steps);
stdout('Rolled back: ' . json_encode($rolled));
break;
case 'status':
$config = loadConfig($args);
$conn = ConnectionManager::make($config);
$dir = migrationsDir($args);
$migrations = array_keys(MigrationLoader::fromDirectory($dir));
$repo = new \Pairity\Migrations\MigrationsRepository($conn);
$ran = $repo->getRan();
$pending = array_values(array_diff($migrations, $ran));
stdout('Ran: ' . json_encode($ran));
stdout('Pending: ' . json_encode($pending));
break;
case 'reset':
$config = loadConfig($args);
$conn = ConnectionManager::make($config);
$dir = migrationsDir($args);
$migrations = MigrationLoader::fromDirectory($dir);
$migrator = new Migrator($conn);
$migrator->setRegistry($migrations);
$totalRolled = [];
while (true) {
$rolled = $migrator->rollback(1);
if (!$rolled) break;
$totalRolled = array_merge($totalRolled, $rolled);
}
stdout('Reset complete. Rolled back: ' . json_encode($totalRolled));
break;
case 'make:migration':
$name = $args[0] ?? null;
if (!$name) {
stderr('Missing migration Name. Usage: pairity make:migration CreateUsersTable [--path=DIR]');
exit(1);
}
$dir = migrationsDir($args);
ensureDir($dir);
$ts = date('Y_m_d_His');
$file = $dir . DIRECTORY_SEPARATOR . $ts . '_' . $name . '.php';
$template = <<<'PHP'
<?php
use Pairity\Migrations\MigrationInterface;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
return new class implements MigrationInterface {
public function up(ConnectionInterface $connection): void
{
// Example: create table
// $schema = SchemaManager::forConnection($connection);
// $schema->create('example', function (Blueprint $t) {
// $t->increments('id');
// $t->string('name', 255);
// });
}
public function down(ConnectionInterface $connection): void
{
// Example: drop table
// $schema = SchemaManager::forConnection($connection);
// $schema->dropIfExists('example');
}
};
PHP;
file_put_contents($file, $template);
stdout('Created: ' . $file);
break;
default:
cmd_help();
break;
}
} catch (Throwable $e) {
stderr('Error: ' . $e->getMessage());
exit(1);
}

31
composer.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "getphred/pairity",
"description": "Partitioned-model PHP ORM (DTO/DAO), Query Builder, Raw SQL, multi-DB via PDO.",
"type": "library",
"license": "MIT",
"authors": [
{ "name": "Pairity Contributors" }
],
"require": {
"php": ">=8.1"
},
"autoload": {
"psr-4": {
"Pairity\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Pairity\\Tests\\": "tests/"
}
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"minimum-stability": "dev",
"prefer-stable": true
,
"bin": [
"bin/pairity"
]
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Pairity\Migrations\MigrationInterface;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
return new class implements MigrationInterface {
public function up(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->table('users', function (Blueprint $t) {
// Add a new nullable column and an index (on status)
$t->string('bio', 500)->nullable();
$t->index(['status'], 'users_status_index');
});
}
public function down(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->table('users', function (Blueprint $t) {
$t->dropIndex('users_status_index');
$t->dropColumn('bio');
});
}
};

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Pairity\Migrations\MigrationInterface;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
return new class implements MigrationInterface {
public function up(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->create('users', function (Blueprint $t) {
$t->increments('id');
$t->string('email', 190);
$t->unique(['email']);
$t->string('name', 255)->nullable();
$t->string('status', 50)->nullable();
$t->timestamps();
$t->datetime('deleted_at')->nullable();
});
}
public function down(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->dropIfExists('users');
}
};

72
examples/mysql_crud.php Normal file
View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
// 1) Configure MySQL connection
$conn = ConnectionManager::make([
'driver' => 'mysql',
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'app',
'username' => 'root',
'password' => 'secret',
'charset' => 'utf8mb4',
]);
// 2) Define DTO, DAO, and Repository for `users` table
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao
{
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
// Demonstrate schema metadata (casts)
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'email' => ['cast' => 'string'],
'name' => ['cast' => 'string'],
'status' => ['cast' => 'string'],
],
// Uncomment if your table has these columns
// 'timestamps' => [ 'createdAt' => 'created_at', 'updatedAt' => 'updated_at' ],
// 'softDeletes' => [ 'enabled' => true, 'deletedAt' => 'deleted_at' ],
];
}
}
$dao = new UserDao($conn);
// 3) Create (INSERT)
$user = new UserDto([
'email' => 'alice@example.com',
'name' => 'Alice',
'status'=> 'active',
]);
$created = $dao->insert($user->toArray());
echo "Created user ID: " . ($created->toArray()['id'] ?? 'N/A') . PHP_EOL;
// 4) Read (SELECT)
$found = $dao->findOneBy(['email' => 'alice@example.com']);
echo 'Found: ' . json_encode($found?->toArray()) . PHP_EOL;
// 5) Update
$data = $found?->toArray() ?? [];
$data['name'] = 'Alice Updated';
$updated = $dao->update($data['id'], ['name' => 'Alice Updated']);
echo 'Updated: ' . json_encode($updated->toArray()) . PHP_EOL;
// 6) Delete
$deleted = $dao->deleteBy(['email' => 'alice@example.com']);
echo "Deleted rows: {$deleted}" . PHP_EOL;

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Migrations\Migrator;
// SQLite connection (file db.sqlite in project root)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Load migrations (here we just include a PHP file returning a MigrationInterface instance)
$createUsers = require __DIR__ . '/migrations/CreateUsersTable.php';
$migrator = new Migrator($conn);
$migrator->setRegistry([
'CreateUsersTable' => $createUsers,
]);
// Apply outstanding migrations
$applied = $migrator->migrate([
'CreateUsersTable' => $createUsers,
]);
echo 'Applied: ' . json_encode($applied) . PHP_EOL;
// To roll back last batch, uncomment:
// $rolled = $migrator->rollback(1);
// echo 'Rolled back: ' . json_encode($rolled) . PHP_EOL;

95
examples/sqlite_crud.php Normal file
View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
// 1) Configure SQLite connection (file db.sqlite in project root)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Create table for demo if not exists
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
name TEXT,
status TEXT,
created_at TEXT NULL,
updated_at TEXT NULL,
deleted_at TEXT NULL
)');
// 2) Define DTO, DAO, and Repository for `users` table
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao
{
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
// Demonstrate schema metadata (casts)
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'email' => ['cast' => 'string'],
'name' => ['cast' => 'string'],
'status' => ['cast' => 'string'],
'created_at' => ['cast' => 'datetime'],
'updated_at' => ['cast' => 'datetime'],
'deleted_at' => ['cast' => 'datetime'],
],
'timestamps' => [ 'createdAt' => 'created_at', 'updatedAt' => 'updated_at' ],
'softDeletes' => [ 'enabled' => true, 'deletedAt' => 'deleted_at' ],
];
}
}
$dao = new UserDao($conn);
// 3) Create (INSERT)
$user = new UserDto([
'email' => 'bob@example.com',
'name' => 'Bob',
'status'=> 'active',
]);
$created = $dao->insert($user->toArray());
echo "Created user ID: " . ($created->toArray()['id'] ?? 'N/A') . PHP_EOL;
// 4) Read (SELECT)
$found = $dao->findOneBy(['email' => 'bob@example.com']);
echo 'Found: ' . json_encode($found?->toArray()) . PHP_EOL;
// 5) Update
$data = $found?->toArray() ?? [];
$data['name'] = 'Bob Updated';
$updated = $dao->update($data['id'], ['name' => 'Bob Updated']);
echo 'Updated: ' . json_encode($updated->toArray()) . PHP_EOL;
// 6) Delete
// 6) Soft Delete
$deleted = $dao->deleteBy(['email' => 'bob@example.com']);
echo "Soft-deleted rows: {$deleted}" . PHP_EOL;
// 7) Query scopes
$all = $dao->withTrashed()->findAllBy();
echo 'All (with trashed): ' . count($all) . PHP_EOL;
$trashedOnly = $dao->onlyTrashed()->findAllBy();
echo 'Only trashed: ' . count($trashedOnly) . PHP_EOL;
// 8) Restore then force delete
if ($found) {
$dao->restoreById($found->toArray()['id']);
echo "Restored ID {$found->toArray()['id']}\n";
$dao->forceDeleteById($found->toArray()['id']);
echo "Force-deleted ID {$found->toArray()['id']}\n";
}

17
phpunit.xml.dist Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Pairity Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View file

@ -0,0 +1,45 @@
<?php
namespace Pairity\Contracts;
interface ConnectionInterface
{
/**
* Execute a SELECT (or any returning) statement and fetch all rows as associative arrays.
*
* @param string $sql
* @param array<string, mixed> $params
* @return array<int, array<string, mixed>>
*/
public function query(string $sql, array $params = []): array;
/**
* Execute a non-SELECT statement (INSERT/UPDATE/DELETE).
*
* @param string $sql
* @param array<string, mixed> $params
* @return int affected rows
*/
public function execute(string $sql, array $params = []): int;
/**
* Run a callback within a transaction.
* Rolls back on throwable and rethrows it.
*
* @template T
* @param callable($this):T $callback
* @return mixed
*/
public function transaction(callable $callback): mixed;
/**
* Return the underlying driver connection (e.g., PDO).
* @return mixed
*/
public function getNative(): mixed;
/**
* Get last inserted ID if supported.
*/
public function lastInsertId(): ?string;
}

View file

@ -0,0 +1,8 @@
<?php
namespace Pairity\Contracts;
interface DaoInterface
{
public function getTable(): string;
}

View file

@ -0,0 +1,15 @@
<?php
namespace Pairity\Contracts;
interface DtoInterface
{
/**
* Convert DTO to array.
* When $deep is true (default), convert any nested DTO relations to arrays as well.
*
* @param bool $deep
* @return array<string,mixed>
*/
public function toArray(bool $deep = true): array;
}

View file

@ -0,0 +1,19 @@
<?php
namespace Pairity\Contracts;
interface QueryBuilderInterface
{
public function select(array $columns): static;
public function from(string $table, ?string $alias = null): static;
public function join(string $type, string $table, string $on): static;
public function where(string $clause, array $bindings = []): static;
public function orderBy(string $orderBy): static;
public function groupBy(string $groupBy): static;
public function having(string $clause, array $bindings = []): static;
public function limit(int $limit): static;
public function offset(int $offset): static;
public function toSql(): string;
/** @return array<string, mixed> */
public function getBindings(): array;
}

View file

@ -0,0 +1,85 @@
<?php
namespace Pairity\Database;
use PDO;
use Pairity\Contracts\ConnectionInterface;
final class ConnectionManager
{
/**
* @param array<string,mixed> $config
*/
public static function make(array $config): ConnectionInterface
{
$driver = strtolower((string)($config['driver'] ?? ''));
if ($driver === '') {
throw new \InvalidArgumentException('Database config must include a driver');
}
[$dsn, $username, $password, $options] = self::buildDsn($driver, $config);
$pdo = new PDO($dsn, $username, $password, $options);
return new PdoConnection($pdo);
}
/**
* @param array<string,mixed> $config
* @return array{0:string,1:?string,2:?string,3:array<string,mixed>}
*/
private static function buildDsn(string $driver, array $config): array
{
$username = $config['username'] ?? null;
$password = $config['password'] ?? null;
$options = $config['options'] ?? [];
switch ($driver) {
case 'mysql':
case 'mariadb':
$host = $config['host'] ?? '127.0.0.1';
$port = (int)($config['port'] ?? 3306);
$db = $config['database'] ?? '';
$charset = $config['charset'] ?? 'utf8mb4';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset={$charset}";
return [$dsn, $username, $password, $options];
case 'pgsql':
case 'postgres':
case 'postgresql':
$host = $config['host'] ?? '127.0.0.1';
$port = (int)($config['port'] ?? 5432);
$db = $config['database'] ?? '';
$dsn = "pgsql:host={$host};port={$port};dbname={$db}";
return [$dsn, $username, $password, $options];
case 'sqlite':
$path = $config['path'] ?? ($config['database'] ?? ':memory:');
$dsn = str_starts_with($path, 'memory') || $path === ':memory:' ? 'sqlite::memory:' : 'sqlite:' . $path;
// For SQLite, username/password are typically null
return [$dsn, null, null, $options];
case 'sqlsrv':
case 'mssql':
$host = $config['host'] ?? '127.0.0.1';
$port = (int)($config['port'] ?? 1433);
$db = $config['database'] ?? '';
$server = $port ? "$host,$port" : $host;
$dsn = "sqlsrv:Server={$server};Database={$db}";
if (!isset($options[PDO::SQLSRV_ATTR_ENCODING])) {
$options[PDO::SQLSRV_ATTR_ENCODING] = PDO::SQLSRV_ENCODING_UTF8;
}
return [$dsn, $username, $password, $options];
case 'oci':
case 'oracle':
$host = $config['host'] ?? '127.0.0.1';
$port = (int)($config['port'] ?? 1521);
$service = $config['service_name'] ?? ($config['sid'] ?? 'XE');
$charset = $config['charset'] ?? 'AL32UTF8';
$dsn = "oci:dbname=//{$host}:{$port}/{$service};charset={$charset}";
return [$dsn, $username, $password, $options];
default:
throw new \InvalidArgumentException("Unsupported driver: {$driver}");
}
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Pairity\Database;
use PDO;
use PDOException;
use Pairity\Contracts\ConnectionInterface;
class PdoConnection implements ConnectionInterface
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
}
public function query(string $sql, array $params = []): array
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public function execute(string $sql, array $params = []): int
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
public function transaction(callable $callback): mixed
{
$this->pdo->beginTransaction();
try {
$result = $callback($this);
$this->pdo->commit();
return $result;
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function getNative(): mixed
{
return $this->pdo;
}
public function lastInsertId(): ?string
{
try {
return $this->pdo->lastInsertId() ?: null;
} catch (PDOException $e) {
return null;
}
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace Pairity\Migrations;
use Pairity\Contracts\ConnectionInterface;
interface MigrationInterface
{
public function up(ConnectionInterface $connection): void;
public function down(ConnectionInterface $connection): void;
}

View file

@ -0,0 +1,38 @@
<?php
namespace Pairity\Migrations;
final class MigrationLoader
{
/**
* Load migrations from a directory.
* Each PHP file should return a MigrationInterface instance or define a class that implements it (autoloadable).
*
* @return array<string,MigrationInterface> Ordered map name => instance
*/
public static function fromDirectory(string $dir): array
{
$result = [];
if (!is_dir($dir)) {
return $result;
}
$files = glob(rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '*.php') ?: [];
sort($files, SORT_STRING);
foreach ($files as $file) {
$name = pathinfo($file, PATHINFO_FILENAME);
$loaded = require $file;
if ($loaded instanceof MigrationInterface) {
$result[$name] = $loaded;
continue;
}
// If file didn't return an instance but defines a class with the same basename, try to instantiate.
if (class_exists($name)) {
$obj = new $name();
if ($obj instanceof MigrationInterface) {
$result[$name] = $obj;
}
}
}
return $result;
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace Pairity\Migrations;
use Pairity\Contracts\ConnectionInterface;
class MigrationsRepository
{
private ConnectionInterface $connection;
private string $table;
public function __construct(ConnectionInterface $connection, string $table = 'migrations')
{
$this->connection = $connection;
$this->table = $table;
}
public function ensureTable(): void
{
// Portable table with string PK works across MySQL & SQLite
$sql = "CREATE TABLE IF NOT EXISTS {$this->table} (
migration VARCHAR(255) PRIMARY KEY,
batch INT NOT NULL,
ran_at DATETIME NOT NULL
)";
$this->connection->execute($sql);
}
/**
* @return array<int,string> migration names already ran
*/
public function getRan(): array
{
$this->ensureTable();
$rows = $this->connection->query("SELECT migration FROM {$this->table} ORDER BY migration ASC");
return array_map(fn($r) => (string)$r['migration'], $rows);
}
public function getLastBatchNumber(): int
{
$this->ensureTable();
$rows = $this->connection->query("SELECT MAX(batch) AS b FROM {$this->table}");
$max = $rows[0]['b'] ?? 0;
return (int)($max ?: 0);
}
public function getNextBatchNumber(): int
{
return $this->getLastBatchNumber() + 1;
}
/** @return array<int,array{migration:string,batch:int,ran_at:string}> */
public function getMigrationsInBatch(int $batch): array
{
$this->ensureTable();
return $this->connection->query("SELECT migration, batch, ran_at FROM {$this->table} WHERE batch = :b ORDER BY migration DESC", ['b' => $batch]);
}
public function log(string $migration, int $batch): void
{
$this->ensureTable();
$this->connection->execute(
"INSERT INTO {$this->table} (migration, batch, ran_at) VALUES (:m, :b, :t)",
['m' => $migration, 'b' => $batch, 't' => gmdate('Y-m-d H:i:s')]
);
}
public function remove(string $migration): void
{
$this->ensureTable();
$this->connection->execute("DELETE FROM {$this->table} WHERE migration = :m", ['m' => $migration]);
}
}

103
src/Migrations/Migrator.php Normal file
View file

@ -0,0 +1,103 @@
<?php
namespace Pairity\Migrations;
use Pairity\Contracts\ConnectionInterface;
class Migrator
{
private ConnectionInterface $connection;
private MigrationsRepository $repository;
/** @var array<string,MigrationInterface> */
private array $registry = [];
public function __construct(ConnectionInterface $connection, ?MigrationsRepository $repository = null)
{
$this->connection = $connection;
$this->repository = $repository ?? new MigrationsRepository($connection);
}
/**
* Provide a registry (name => migration instance) used for rollback/reset resolution.
*
* @param array<string,MigrationInterface> $registry
*/
public function setRegistry(array $registry): void
{
$this->registry = $registry;
}
/**
* Run outstanding migrations.
*
* @param array<string,MigrationInterface> $migrations An ordered map of name => instance
* @return array<int,string> List of applied migration names
*/
public function migrate(array $migrations): array
{
$this->repository->ensureTable();
$ran = array_flip($this->repository->getRan());
$batch = $this->repository->getNextBatchNumber();
$applied = [];
foreach ($migrations as $name => $migration) {
if (isset($ran[$name])) {
continue; // already ran
}
// keep in registry for potential rollback in the same process
$this->registry[$name] = $migration;
$this->connection->transaction(function () use ($migration, $name, $batch, &$applied) {
$migration->up($this->connection);
$this->repository->log($name, $batch);
$applied[] = $name;
});
}
return $applied;
}
/**
* Roll back the last batch (or N steps of batches).
*
* @return array<int,string> List of rolled back migration names
*/
public function rollback(int $steps = 1): array
{
$this->repository->ensureTable();
$rolled = [];
for ($i = 0; $i < $steps; $i++) {
$batch = $this->repository->getLastBatchNumber();
if ($batch <= 0) { break; }
$items = $this->repository->getMigrationsInBatch($batch);
if (!$items) { break; }
foreach ($items as $row) {
$name = (string)$row['migration'];
$instance = $this->resolveMigration($name);
if (!$instance) { continue; }
$this->connection->transaction(function () use ($instance, $name, &$rolled) {
$instance->down($this->connection);
$this->repository->remove($name);
$rolled[] = $name;
});
}
}
return $rolled;
}
/**
* Resolve a migration by name from registry or instantiate by class name.
*/
private function resolveMigration(string $name): ?MigrationInterface
{
if (isset($this->registry[$name])) {
return $this->registry[$name];
}
if (class_exists($name)) {
$obj = new $name();
if ($obj instanceof MigrationInterface) {
return $obj;
}
}
return null;
}
}

726
src/Model/AbstractDao.php Normal file
View file

@ -0,0 +1,726 @@
<?php
namespace Pairity\Model;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Contracts\DaoInterface;
abstract class AbstractDao implements DaoInterface
{
protected ConnectionInterface $connection;
protected string $primaryKey = 'id';
/** @var array<int,string>|null */
private ?array $selectedFields = null;
/** @var array<string, array<int,string>> */
private array $relationFields = [];
/** @var array<int,string> */
private array $with = [];
/** Soft delete include flags */
private bool $includeTrashed = false;
private bool $onlyTrashed = false;
public function __construct(ConnectionInterface $connection)
{
$this->connection = $connection;
}
abstract public function getTable(): string;
/**
* The DTO class this DAO hydrates.
* @return class-string<AbstractDto>
*/
abstract protected function dtoClass(): string;
/**
* Relation metadata to enable eager/lazy loading.
* @return array<string, array<string, mixed>>
*/
protected function relations(): array
{
return [];
}
/**
* Optional schema metadata for this DAO (MVP).
* Example structure:
* return [
* 'primaryKey' => 'id',
* 'columns' => [
* 'id' => ['cast' => 'int'],
* 'email' => ['cast' => 'string'],
* 'data' => ['cast' => 'json'],
* ],
* 'timestamps' => ['createdAt' => 'created_at', 'updatedAt' => 'updated_at'],
* 'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
* ];
*
* @return array<string, mixed>
*/
protected function schema(): array
{
return [];
}
public function getPrimaryKey(): string
{
$schema = $this->getSchema();
if (isset($schema['primaryKey']) && is_string($schema['primaryKey']) && $schema['primaryKey'] !== '') {
return $schema['primaryKey'];
}
return $this->primaryKey;
}
public function getConnection(): ConnectionInterface
{
return $this->connection;
}
/** @param array<string,mixed> $criteria */
public function findOneBy(array $criteria): ?AbstractDto
{
[$where, $bindings] = $this->buildWhere($this->applyDefaultScopes($criteria));
$where = $this->appendScopedWhere($where);
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '') . ' LIMIT 1';
$rows = $this->connection->query($sql, $bindings);
$dto = isset($rows[0]) ? $this->hydrate($this->castRowFromStorage($rows[0])) : null;
if ($dto && $this->with) {
$this->attachRelations([$dto]);
}
$this->resetFieldSelections();
return $dto;
}
public function findById(int|string $id): ?AbstractDto
{
return $this->findOneBy([$this->getPrimaryKey() => $id]);
}
/**
* @param array<string,mixed> $criteria
* @return array<int, AbstractDto>
*/
public function findAllBy(array $criteria = []): array
{
[$where, $bindings] = $this->buildWhere($this->applyDefaultScopes($criteria));
$where = $this->appendScopedWhere($where);
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '');
$rows = $this->connection->query($sql, $bindings);
$dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows);
if ($dtos && $this->with) {
$this->attachRelations($dtos);
}
$this->resetFieldSelections();
return $dtos;
}
/** @param array<string,mixed> $data */
public function insert(array $data): AbstractDto
{
if (empty($data)) {
throw new \InvalidArgumentException('insert() requires non-empty data');
}
$data = $this->prepareForInsert($data);
$cols = array_keys($data);
$placeholders = array_map(fn($c) => ':' . $c, $cols);
$sql = 'INSERT INTO ' . $this->getTable() . ' (' . implode(', ', $cols) . ') VALUES (' . implode(', ', $placeholders) . ')';
$this->connection->execute($sql, $data);
$id = $this->connection->lastInsertId();
$pk = $this->getPrimaryKey();
if ($id !== null) {
return $this->findById($id) ?? $this->hydrate(array_merge($data, [$pk => $id]));
}
// Fallback when lastInsertId is unavailable: return hydrated DTO from provided data
return $this->hydrate($this->castRowFromStorage($data));
}
/** @param array<string,mixed> $data */
public function update(int|string $id, array $data): AbstractDto
{
if (empty($data)) {
$existing = $this->findById($id);
if ($existing) return $existing;
throw new \InvalidArgumentException('No data provided to update and record not found');
}
$data = $this->prepareForUpdate($data);
$sets = [];
$params = [];
foreach ($data as $col => $val) {
$sets[] = "$col = :set_$col";
$params["set_$col"] = $val;
}
$params['pk'] = $id;
$sql = 'UPDATE ' . $this->getTable() . ' SET ' . implode(', ', $sets) . ' WHERE ' . $this->getPrimaryKey() . ' = :pk';
$this->connection->execute($sql, $params);
$updated = $this->findById($id);
if ($updated === null) {
// As a fallback, hydrate using provided data + id
$pk = $this->getPrimaryKey();
return $this->hydrate($this->castRowFromStorage(array_merge($data, [$pk => $id])));
}
return $updated;
}
public function deleteById(int|string $id): int
{
if ($this->hasSoftDeletes()) {
$columns = $this->softDeleteConfig();
$deletedAt = $columns['deletedAt'] ?? 'deleted_at';
$now = $this->nowString();
$sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = :ts WHERE " . $this->getPrimaryKey() . ' = :pk';
return $this->connection->execute($sql, ['ts' => $now, 'pk' => $id]);
}
$sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $this->getPrimaryKey() . ' = :pk';
return $this->connection->execute($sql, ['pk' => $id]);
}
/** @param array<string,mixed> $criteria */
public function deleteBy(array $criteria): int
{
if ($this->hasSoftDeletes()) {
[$where, $bindings] = $this->buildWhere($criteria);
if ($where === '') { return 0; }
$columns = $this->softDeleteConfig();
$deletedAt = $columns['deletedAt'] ?? 'deleted_at';
$now = $this->nowString();
$sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = :ts WHERE " . $where;
$bindings = array_merge(['ts' => $now], $bindings);
return $this->connection->execute($sql, $bindings);
}
[$where, $bindings] = $this->buildWhere($criteria);
if ($where === '') { return 0; }
$sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $where;
return $this->connection->execute($sql, $bindings);
}
/**
* Update rows matching the given criteria with the provided data.
*
* @param array<string,mixed> $criteria
* @param array<string,mixed> $data
*/
public function updateBy(array $criteria, array $data): int
{
if (empty($data)) {
return 0;
}
[$where, $whereBindings] = $this->buildWhere($criteria);
if ($where === '') {
return 0;
}
// Ensure timestamps and storage casts are applied consistently with update()
$data = $this->prepareForUpdate($data);
$sets = [];
$setParams = [];
foreach ($data as $col => $val) {
$sets[] = "$col = :set_$col";
$setParams["set_$col"] = $val;
}
$sql = 'UPDATE ' . $this->getTable() . ' SET ' . implode(', ', $sets) . ' WHERE ' . $where;
return $this->connection->execute($sql, array_merge($setParams, $whereBindings));
}
/**
* @param array<string,mixed> $criteria
* @return array{0:string,1:array<string,mixed>}
*/
protected function buildWhere(array $criteria): array
{
if (!$criteria) {
return ['', []];
}
$parts = [];
$bindings = [];
foreach ($criteria as $col => $val) {
$param = 'w_' . preg_replace('/[^a-zA-Z0-9_]/', '_', (string)$col);
if ($val === null) {
$parts[] = "$col IS NULL";
} else {
$parts[] = "$col = :$param";
$bindings[$param] = $val;
}
}
return [implode(' AND ', $parts), $bindings];
}
/**
* Fetch all rows where a column is within the given set of values.
*
* @param string $column
* @param array<int, int|string> $values
* @return array<int, array<string,mixed>>
*/
/**
* Fetch related rows where a column is within a set of values.
* Returns DTOs.
*
* @param string $column
* @param array<int, int|string> $values
* @param array<int,string>|null $selectFields If provided, use these fields instead of the DAO's current selection
* @return array<int, AbstractDto>
*/
public function findAllWhereIn(string $column, array $values, ?array $selectFields = null): array
{
if (empty($values)) {
return [];
}
$values = array_values(array_unique($values, SORT_REGULAR));
$placeholders = [];
$bindings = [];
foreach ($values as $i => $val) {
$ph = "in_{$i}";
$placeholders[] = ":{$ph}";
$bindings[$ph] = $val;
}
$selectList = $selectFields && $selectFields !== ['*']
? implode(', ', $selectFields)
: $this->selectList();
$where = $column . ' IN (' . implode(', ', $placeholders) . ')';
$where = $this->appendScopedWhere($where);
$sql = 'SELECT ' . $selectList . ' FROM ' . $this->getTable() . ' WHERE ' . $where;
$rows = $this->connection->query($sql, $bindings);
return array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows);
}
/**
* Magic dynamic find/update/delete helpers:
* - findOneBy{Column}($value)
* - findAllBy{Column}($value)
* - updateBy{Column}($value, array $data)
* - deleteBy{Column}($value)
*/
public function __call(string $name, array $arguments): mixed
{
if (preg_match('/^(findOneBy|findAllBy|updateBy|deleteBy)([A-Z][A-Za-z0-9_]*)$/', $name, $m)) {
$op = $m[1];
$colPart = $m[2];
$column = $this->normalizeColumn($colPart);
switch ($op) {
case 'findOneBy':
$value = $arguments[0] ?? null;
return $this->findOneBy([$column => $value]);
case 'findAllBy':
$value = $arguments[0] ?? null;
return $this->findAllBy([$column => $value]);
case 'updateBy':
$value = $arguments[0] ?? null;
$data = $arguments[1] ?? [];
if (!is_array($data)) {
throw new \InvalidArgumentException('updateBy* expects second argument as array $data');
}
return $this->updateBy([$column => $value], $data);
case 'deleteBy':
$value = $arguments[0] ?? null;
return $this->deleteBy([$column => $value]);
}
}
throw new \BadMethodCallException(static::class . "::{$name} does not exist");
}
protected function normalizeColumn(string $studly): string
{
// Convert StudlyCase/CamelCase to snake_case and lowercase
$snake = preg_replace('/(?<!^)[A-Z]/', '_$0', $studly) ?? $studly;
return strtolower($snake);
}
/**
* Specify fields to select on the base entity and optionally on relations via dot-notation.
* Example: fields('id', 'name', 'posts.title')
*/
public function fields(string ...$fields): static
{
$base = [];
foreach ($fields as $f) {
if (str_contains($f, '.')) {
[$rel, $col] = explode('.', $f, 2);
if ($rel !== '') {
$this->relationFields[$rel][] = $col;
}
} else {
if ($f !== '') { $base[] = $f; }
}
}
if ($base) {
$this->selectedFields = $base;
} else {
$this->selectedFields = $this->selectedFields ?? null;
}
return $this;
}
/** @param array<int, AbstractDto> $parents */
protected function attachRelations(array $parents): void
{
if (!$parents) return;
$relations = $this->relations();
foreach ($this->with as $name) {
if (!isset($relations[$name])) {
continue; // silently ignore unknown
}
$config = $relations[$name];
$type = (string)($config['type'] ?? '');
$daoClass = $config['dao'] ?? null;
$dtoClass = $config['dto'] ?? null; // kept for docs compatibility
if (!is_string($daoClass)) { continue; }
/** @var class-string<AbstractDao> $daoClass */
$relatedDao = new $daoClass($this->getConnection());
$relFields = $this->relationFields[$name] ?? null;
if ($relFields) { $relatedDao->fields(...$relFields); }
if ($type === 'hasMany' || $type === 'hasOne') {
$foreignKey = (string)($config['foreignKey'] ?? '');
$localKey = (string)($config['localKey'] ?? 'id');
if ($foreignKey === '') continue;
$keys = [];
foreach ($parents as $p) {
$arr = $p->toArray();
if (isset($arr[$localKey])) { $keys[] = $arr[$localKey]; }
}
if (!$keys) continue;
$children = $relatedDao->findAllWhereIn($foreignKey, $keys);
// group children by foreignKey value
$grouped = [];
foreach ($children as $child) {
$fk = $child->toArray()[$foreignKey] ?? null;
if ($fk === null) continue;
$grouped[$fk][] = $child;
}
foreach ($parents as $p) {
$arr = $p->toArray();
$key = $arr[$localKey] ?? null;
$list = ($key !== null && isset($grouped[$key])) ? $grouped[$key] : [];
if ($type === 'hasOne') {
$first = $list[0] ?? null;
$p->setRelation($name, $first);
} else {
$p->setRelation($name, $list);
}
}
} elseif ($type === 'belongsTo') {
$foreignKey = (string)($config['foreignKey'] ?? ''); // on parent
$otherKey = (string)($config['otherKey'] ?? 'id'); // on related
if ($foreignKey === '') continue;
$ownerIds = [];
foreach ($parents as $p) {
$arr = $p->toArray();
if (isset($arr[$foreignKey])) { $ownerIds[] = $arr[$foreignKey]; }
}
if (!$ownerIds) continue;
$owners = $relatedDao->findAllWhereIn($otherKey, $ownerIds);
$byId = [];
foreach ($owners as $o) {
$id = $o->toArray()[$otherKey] ?? null;
if ($id !== null) { $byId[$id] = $o; }
}
foreach ($parents as $p) {
$arr = $p->toArray();
$fk = $arr[$foreignKey] ?? null;
$p->setRelation($name, ($fk !== null && isset($byId[$fk])) ? $byId[$fk] : null);
}
}
}
// reset eager-load request after use
$this->with = [];
// do not reset relationFields here; they may be reused by subsequent loads in the same call
}
public function with(array $relations): static
{
$this->with = $relations;
return $this;
}
public function load(AbstractDto $dto, string $relation): void
{
$this->with([$relation]);
$this->attachRelations([$dto]);
}
/** @param array<int, AbstractDto> $dtos */
public function loadMany(array $dtos, string $relation): void
{
if (!$dtos) return;
$this->with([$relation]);
$this->attachRelations($dtos);
}
protected function hydrate(array $row): AbstractDto
{
$class = $this->dtoClass();
/** @var AbstractDto $dto */
$dto = $class::fromArray($row);
return $dto;
}
private function selectList(): string
{
if ($this->selectedFields && $this->selectedFields !== ['*']) {
return implode(', ', $this->selectedFields);
}
// By default, select all columns when fields() is not used.
return '*';
}
private function resetFieldSelections(): void
{
$this->selectedFields = null;
$this->relationFields = [];
$this->includeTrashed = false;
$this->onlyTrashed = false;
}
// ===== Schema helpers & behaviors =====
protected function getSchema(): array
{
return $this->schema();
}
protected function hasSoftDeletes(): bool
{
$sd = $this->getSchema()['softDeletes'] ?? null;
return is_array($sd) && !empty($sd['enabled']);
}
/** @return array{deletedAt?:string} */
protected function softDeleteConfig(): array
{
$sd = $this->getSchema()['softDeletes'] ?? [];
return is_array($sd) ? $sd : [];
}
/** @return array{createdAt?:string,updatedAt?:string} */
protected function timestampsConfig(): array
{
$ts = $this->getSchema()['timestamps'] ?? [];
return is_array($ts) ? $ts : [];
}
/** Returns array<string,string> cast map col=>type */
protected function castsMap(): array
{
$cols = $this->getSchema()['columns'] ?? [];
if (!is_array($cols)) return [];
$map = [];
foreach ($cols as $name => $meta) {
if (is_array($meta) && isset($meta['cast']) && is_string($meta['cast'])) {
$map[$name] = $meta['cast'];
}
}
return $map;
}
// Note: default SELECT projection now always '*' unless fields() is used.
/**
* Apply default scopes (e.g., soft deletes) to criteria.
* For now, we don't alter criteria array; soft delete is appended as SQL fragment.
* This method allows future transformations.
* @param array<string,mixed> $criteria
* @return array<string,mixed>
*/
protected function applyDefaultScopes(array $criteria): array
{
return $criteria;
}
/** Append soft-delete scope to a WHERE clause string (without bindings). */
private function appendScopedWhere(string $where): string
{
if (!$this->hasSoftDeletes()) return $where;
$deletedAt = $this->softDeleteConfig()['deletedAt'] ?? 'deleted_at';
$frag = '';
if ($this->onlyTrashed) {
$frag = "{$deletedAt} IS NOT NULL";
} elseif (!$this->includeTrashed) {
$frag = "{$deletedAt} IS NULL";
}
if ($frag === '') return $where;
if ($where === '' ) return $frag;
return $where . ' AND ' . $frag;
}
/** Cast a database row to PHP types according to schema casts. */
private function castRowFromStorage(array $row): array
{
$casts = $this->castsMap();
if (!$casts) return $row;
foreach ($casts as $col => $type) {
if (!array_key_exists($col, $row)) continue;
$row[$col] = $this->castFromStorage($type, $row[$col]);
}
return $row;
}
private function castFromStorage(string $type, mixed $value): mixed
{
if ($value === null) return null;
switch ($type) {
case 'int': return (int)$value;
case 'float': return (float)$value;
case 'bool': return (bool)$value;
case 'string': return (string)$value;
case 'json':
if (is_string($value)) {
$decoded = json_decode($value, true);
return (json_last_error() === JSON_ERROR_NONE) ? $decoded : $value;
}
return $value;
case 'datetime':
try {
return new \DateTimeImmutable(is_string($value) ? $value : (string)$value);
} catch (\Throwable) {
return $value;
}
default:
return $value;
}
}
/** Prepare data for INSERT: filter known columns, auto timestamps, storage casting. */
private function prepareForInsert(array $data): array
{
$data = $this->filterToKnownColumns($data);
// timestamps
$ts = $this->timestampsConfig();
$now = $this->nowString();
if (!empty($ts['createdAt']) && !array_key_exists($ts['createdAt'], $data)) {
$data[$ts['createdAt']] = $now;
}
if (!empty($ts['updatedAt']) && !array_key_exists($ts['updatedAt'], $data)) {
$data[$ts['updatedAt']] = $now;
}
return $this->castForStorageAll($data);
}
/** Prepare data for UPDATE: filter known columns, auto updatedAt, storage casting. */
private function prepareForUpdate(array $data): array
{
$data = $this->filterToKnownColumns($data);
$ts = $this->timestampsConfig();
if (!empty($ts['updatedAt'])) {
$data[$ts['updatedAt']] = $this->nowString();
}
return $this->castForStorageAll($data);
}
/** Keep only keys defined in schema columns (if any). */
private function filterToKnownColumns(array $data): array
{
$cols = $this->getSchema()['columns'] ?? null;
if (!is_array($cols) || !$cols) return $data;
$allowed = array_fill_keys(array_keys($cols), true);
return array_intersect_key($data, $allowed);
}
private function castForStorageAll(array $data): array
{
$casts = $this->castsMap();
if (!$casts) return $data;
foreach ($data as $k => $v) {
if (isset($casts[$k])) {
$data[$k] = $this->castForStorage($casts[$k], $v);
}
}
return $data;
}
private function castForStorage(string $type, mixed $value): mixed
{
if ($value === null) return null;
switch ($type) {
case 'int': return (int)$value;
case 'float': return (float)$value;
case 'bool': return (int)((bool)$value); // store as 0/1 for portability
case 'string': return (string)$value;
case 'json':
if (is_string($value)) return $value;
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
case 'datetime':
if ($value instanceof \DateTimeInterface) {
$utc = (new \DateTimeImmutable('@' . $value->getTimestamp()))->setTimezone(new \DateTimeZone('UTC'));
return $utc->format('Y-m-d H:i:s');
}
return (string)$value;
default:
return $value;
}
}
private function nowString(): string
{
return gmdate('Y-m-d H:i:s');
}
// ===== Soft delete toggles =====
public function withTrashed(): static
{
$this->includeTrashed = true;
$this->onlyTrashed = false;
return $this;
}
public function onlyTrashed(): static
{
$this->includeTrashed = true;
$this->onlyTrashed = true;
return $this;
}
// ===== Soft delete helpers & utilities =====
/** Restore a soft-deleted row by primary key. No-op when soft deletes are disabled. */
public function restoreById(int|string $id): int
{
if (!$this->hasSoftDeletes()) { return 0; }
$deletedAt = $this->softDeleteConfig()['deletedAt'] ?? 'deleted_at';
$sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = NULL WHERE " . $this->getPrimaryKey() . ' = :pk';
return $this->connection->execute($sql, ['pk' => $id]);
}
/** Restore rows matching criteria. No-op when soft deletes are disabled. */
public function restoreBy(array $criteria): int
{
if (!$this->hasSoftDeletes()) { return 0; }
[$where, $bindings] = $this->buildWhere($criteria);
if ($where === '') { return 0; }
$deletedAt = $this->softDeleteConfig()['deletedAt'] ?? 'deleted_at';
$sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = NULL WHERE " . $where;
return $this->connection->execute($sql, $bindings);
}
/** Permanently delete a row by id even when soft deletes are enabled. */
public function forceDeleteById(int|string $id): int
{
$sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $this->getPrimaryKey() . ' = :pk';
return $this->connection->execute($sql, ['pk' => $id]);
}
/** Permanently delete rows matching criteria even when soft deletes are enabled. */
public function forceDeleteBy(array $criteria): int
{
[$where, $bindings] = $this->buildWhere($criteria);
if ($where === '') { return 0; }
$sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $where;
return $this->connection->execute($sql, $bindings);
}
/** Touch a row by updating only the configured updatedAt column, if timestamps are enabled. */
public function touch(int|string $id): int
{
$ts = $this->timestampsConfig();
if (empty($ts['updatedAt'])) { return 0; }
$col = $ts['updatedAt'];
$sql = 'UPDATE ' . $this->getTable() . " SET {$col} = :ts WHERE " . $this->getPrimaryKey() . ' = :pk';
return $this->connection->execute($sql, ['ts' => $this->nowString(), 'pk' => $id]);
}
}

69
src/Model/AbstractDto.php Normal file
View file

@ -0,0 +1,69 @@
<?php
namespace Pairity\Model;
use Pairity\Contracts\DtoInterface;
abstract class AbstractDto implements DtoInterface
{
/** @var array<string,mixed> */
protected array $attributes = [];
/** @param array<string,mixed> $attributes */
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
}
/** @param array<string,mixed> $data */
public static function fromArray(array $data): static
{
return new static($data);
}
public function __get(string $name): mixed
{
return $this->attributes[$name] ?? null;
}
public function __isset(string $name): bool
{
return array_key_exists($name, $this->attributes);
}
/**
* Attach a loaded relation or transient attribute to the DTO.
* Intended for internal ORM use (eager/lazy loading).
*/
public function setRelation(string $name, mixed $value): void
{
$this->attributes[$name] = $value;
}
/** @return array<string,mixed> */
public function toArray(bool $deep = true): array
{
if (!$deep) {
return $this->attributes;
}
$result = [];
foreach ($this->attributes as $key => $value) {
if ($value instanceof DtoInterface) {
$result[$key] = $value->toArray(true);
} elseif (is_array($value)) {
// Map arrays, converting any DTO elements to arrays as well
$result[$key] = array_map(function ($item) {
if ($item instanceof DtoInterface) {
return $item->toArray(true);
}
return $item;
}, $value);
} else {
$result[$key] = $value;
}
}
return $result;
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace Pairity\NoSql\Mongo;
/**
* Minimal MongoDB stub. This implementation keeps data in-memory for demo/testing
* without introducing external dependencies. It mimics a subset of operations.
* Replace with a real adapter wrapping `mongodb/mongodb` later.
*/
class MongoConnection implements MongoConnectionInterface
{
/**
* @var array<string, array<string, array<int, array<string, mixed>>>>
* $store[db][collection][] = document
*/
private array $store = [];
public function find(string $database, string $collection, array $filter = [], array $options = []): iterable
{
$docs = $this->getCollection($database, $collection);
$result = [];
foreach ($docs as $doc) {
if ($this->matches($doc, $filter)) {
$result[] = $doc;
}
}
return $result;
}
public function insertOne(string $database, string $collection, array $document): string
{
$document['_id'] = $document['_id'] ?? $this->generateId();
$this->store[$database][$collection][] = $document;
return (string)$document['_id'];
}
public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int
{
$docs =& $this->store[$database][$collection];
if (!is_array($docs)) {
$docs = [];
}
foreach ($docs as &$doc) {
if ($this->matches($doc, $filter)) {
// Very naive: support direct field set or $set operator
if (isset($update['$set']) && is_array($update['$set'])) {
foreach ($update['$set'] as $k => $v) {
$doc[$k] = $v;
}
} else {
foreach ($update as $k => $v) {
$doc[$k] = $v;
}
}
return 1;
}
}
return 0;
}
public function deleteOne(string $database, string $collection, array $filter, array $options = []): int
{
$docs =& $this->store[$database][$collection];
if (!is_array($docs)) {
$docs = [];
}
foreach ($docs as $i => $doc) {
if ($this->matches($doc, $filter)) {
array_splice($docs, $i, 1);
return 1;
}
}
return 0;
}
public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable
{
// Stub: no real pipeline support; just return all docs
return $this->getCollection($database, $collection);
}
private function &getCollection(string $database, string $collection): array
{
if (!isset($this->store[$database][$collection])) {
$this->store[$database][$collection] = [];
}
return $this->store[$database][$collection];
}
/** @param array<string,mixed> $doc @param array<string,mixed> $filter */
private function matches(array $doc, array $filter): bool
{
foreach ($filter as $k => $v) {
if (!array_key_exists($k, $doc) || $doc[$k] !== $v) {
return false;
}
}
return true;
}
private function generateId(): string
{
return bin2hex(random_bytes(12));
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Pairity\NoSql\Mongo;
interface MongoConnectionInterface
{
/** @return iterable<int, array<string,mixed>> */
public function find(string $database, string $collection, array $filter = [], array $options = []): iterable;
/** @param array<string,mixed> $document */
public function insertOne(string $database, string $collection, array $document): string;
/** @param array<string,mixed> $filter @param array<string,mixed> $update */
public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int;
/** @param array<string,mixed> $filter */
public function deleteOne(string $database, string $collection, array $filter, array $options = []): int;
/** @param array<int, array<string,mixed>> $pipeline */
public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable;
}

120
src/Query/QueryBuilder.php Normal file
View file

@ -0,0 +1,120 @@
<?php
namespace Pairity\Query;
use Pairity\Contracts\QueryBuilderInterface;
class QueryBuilder implements QueryBuilderInterface
{
private array $columns = ['*'];
private string $from = '';
private ?string $alias = null;
private array $joins = [];
private array $wheres = [];
private array $groupBys = [];
private array $havings = [];
private array $orderBys = [];
private ?int $limitVal = null;
private ?int $offsetVal = null;
/** @var array<string,mixed> */
private array $bindings = [];
public function select(array $columns): static
{
$this->columns = $columns ?: ['*'];
return $this;
}
public function from(string $table, ?string $alias = null): static
{
$this->from = $table;
$this->alias = $alias;
return $this;
}
public function join(string $type, string $table, string $on): static
{
$this->joins[] = trim(strtoupper($type)) . " JOIN {$table} ON {$on}";
return $this;
}
public function where(string $clause, array $bindings = []): static
{
$this->wheres[] = $clause;
foreach ($bindings as $k => $v) {
$this->bindings[$k] = $v;
}
return $this;
}
public function orderBy(string $orderBy): static
{
$this->orderBys[] = $orderBy;
return $this;
}
public function groupBy(string $groupBy): static
{
$this->groupBys[] = $groupBy;
return $this;
}
public function having(string $clause, array $bindings = []): static
{
$this->havings[] = $clause;
foreach ($bindings as $k => $v) {
$this->bindings[$k] = $v;
}
return $this;
}
public function limit(int $limit): static
{
$this->limitVal = $limit;
return $this;
}
public function offset(int $offset): static
{
$this->offsetVal = $offset;
return $this;
}
public function toSql(): string
{
$sql = 'SELECT ' . implode(', ', $this->columns);
if ($this->from) {
$sql .= ' FROM ' . $this->from;
if ($this->alias) {
$sql .= ' ' . $this->alias;
}
}
if ($this->joins) {
$sql .= ' ' . implode(' ', $this->joins);
}
if ($this->wheres) {
$sql .= ' WHERE ' . implode(' AND ', $this->wheres);
}
if ($this->groupBys) {
$sql .= ' GROUP BY ' . implode(', ', $this->groupBys);
}
if ($this->havings) {
$sql .= ' HAVING ' . implode(' AND ', $this->havings);
}
if ($this->orderBys) {
$sql .= ' ORDER BY ' . implode(', ', $this->orderBys);
}
if ($this->limitVal !== null) {
$sql .= ' LIMIT ' . $this->limitVal;
}
if ($this->offsetVal !== null) {
$sql .= ' OFFSET ' . $this->offsetVal;
}
return $sql;
}
public function getBindings(): array
{
return $this->bindings;
}
}

170
src/Schema/Blueprint.php Normal file
View file

@ -0,0 +1,170 @@
<?php
namespace Pairity\Schema;
class Blueprint
{
public string $table;
public bool $creating = false;
public bool $altering = false;
/** @var array<int,ColumnDefinition> */
public array $columns = [];
/** @var array<int,string> */
public array $primary = [];
/** @var array<int,array{columns:array<int,string>,name:?string}> */
public array $uniques = [];
/** @var array<int,array{columns:array<int,string>,name:?string}> */
public array $indexes = [];
// Alter support (MVP)
/** @var array<int,string> */
public array $dropColumns = [];
/** @var array<int,array{from:string,to:string}> */
public array $renameColumns = [];
public ?string $renameTo = null;
/** @var array<int,string> */
public array $dropUniqueNames = [];
/** @var array<int,string> */
public array $dropIndexNames = [];
public function __construct(string $table)
{
$this->table = $table;
}
public function create(): void { $this->creating = true; }
public function alter(): void { $this->altering = true; }
// Column helpers
public function increments(string $name = 'id'): ColumnDefinition
{
$col = new ColumnDefinition($name, 'increments');
$col->autoIncrement(true);
$this->columns[] = $col;
$this->primary([$name]);
return $col;
}
public function bigIncrements(string $name = 'id'): ColumnDefinition
{
$col = new ColumnDefinition($name, 'bigincrements');
$col->autoIncrement(true);
$this->columns[] = $col;
$this->primary([$name]);
return $col;
}
public function integer(string $name, bool $unsigned = false): ColumnDefinition
{
$col = new ColumnDefinition($name, 'integer');
$col->unsigned($unsigned);
$this->columns[] = $col;
return $col;
}
public function bigInteger(string $name, bool $unsigned = false): ColumnDefinition
{
$col = new ColumnDefinition($name, 'biginteger');
$col->unsigned($unsigned);
$this->columns[] = $col;
return $col;
}
public function string(string $name, int $length = 255): ColumnDefinition
{
$col = new ColumnDefinition($name, 'string');
$col->length($length);
$this->columns[] = $col;
return $col;
}
public function text(string $name): ColumnDefinition
{
$col = new ColumnDefinition($name, 'text');
$this->columns[] = $col;
return $col;
}
public function boolean(string $name): ColumnDefinition
{
$col = new ColumnDefinition($name, 'boolean');
$this->columns[] = $col;
return $col;
}
public function json(string $name): ColumnDefinition
{
$col = new ColumnDefinition($name, 'json');
$this->columns[] = $col;
return $col;
}
public function datetime(string $name): ColumnDefinition
{
$col = new ColumnDefinition($name, 'datetime');
$this->columns[] = $col;
return $col;
}
public function decimal(string $name, int $precision, int $scale = 0): ColumnDefinition
{
$col = new ColumnDefinition($name, 'decimal');
$col->precision($precision, $scale);
$this->columns[] = $col;
return $col;
}
public function timestamps(string $created = 'created_at', string $updated = 'updated_at'): void
{
$this->datetime($created)->nullable();
$this->datetime($updated)->nullable();
}
// Index helpers
/** @param array<int,string> $columns */
public function primary(array $columns): void
{
$this->primary = $columns;
}
/** @param array<int,string> $columns */
public function unique(array $columns, ?string $name = null): void
{
$this->uniques[] = ['columns' => $columns, 'name' => $name];
}
/** @param array<int,string> $columns */
public function index(array $columns, ?string $name = null): void
{
$this->indexes[] = ['columns' => $columns, 'name' => $name];
}
// Alter helpers (MVP)
/** @param array<int,string> $names */
public function dropColumn(string ...$names): void
{
foreach ($names as $n) {
if ($n !== '') $this->dropColumns[] = $n;
}
}
public function renameColumn(string $from, string $to): void
{
$this->renameColumns[] = ['from' => $from, 'to' => $to];
}
public function rename(string $newName): void
{
$this->renameTo = $newName;
}
public function dropUnique(string $name): void
{
$this->dropUniqueNames[] = $name;
}
public function dropIndex(string $name): void
{
$this->dropIndexNames[] = $name;
}
}

86
src/Schema/Builder.php Normal file
View file

@ -0,0 +1,86 @@
<?php
namespace Pairity\Schema;
use Closure;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\Grammars\Grammar;
use Pairity\Schema\Grammars\SqliteGrammar;
class Builder
{
private ConnectionInterface $connection;
private Grammar $grammar;
public function __construct(ConnectionInterface $connection, Grammar $grammar)
{
$this->connection = $connection;
$this->grammar = $grammar;
}
public function create(string $table, Closure $callback): void
{
$blueprint = new Blueprint($table);
$blueprint->create();
$callback($blueprint);
$this->run($this->grammar->compileCreate($blueprint));
}
public function drop(string $table): void
{
$this->run($this->grammar->compileDrop($table));
}
public function dropIfExists(string $table): void
{
$this->run($this->grammar->compileDropIfExists($table));
}
/**
* Alter an existing table using the blueprint alter helpers.
*/
public function table(string $table, Closure $callback): void
{
$blueprint = new Blueprint($table);
$blueprint->alter();
$callback($blueprint);
// If SQLite and operation requires rebuild on legacy versions, perform rebuild
if ($this->grammar instanceof SqliteGrammar && ($blueprint->dropColumns || $blueprint->renameColumns)) {
$version = $this->detectSqliteVersion();
$needsRebuild = false;
if ($blueprint->renameColumns) {
// RENAME COLUMN requires >= 3.25
$needsRebuild = $needsRebuild || version_compare($version, '3.25.0', '<');
}
if ($blueprint->dropColumns) {
// DROP COLUMN requires >= 3.35
$needsRebuild = $needsRebuild || version_compare($version, '3.35.0', '<');
}
if ($needsRebuild) {
SqliteTableRebuilder::rebuild($this->connection, $blueprint, $this->grammar);
return;
}
}
$this->run($this->grammar->compileAlter($blueprint));
}
/** @param array<int,string> $sqls */
private function run(array $sqls): void
{
foreach ($sqls as $sql) {
$this->connection->execute($sql);
}
}
private function detectSqliteVersion(): string
{
try {
$rows = $this->connection->query('select sqlite_version() as v');
$v = $rows[0]['v'] ?? '3.0.0';
return is_string($v) ? $v : '3.0.0';
} catch (\Throwable) {
return '3.0.0';
}
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Pairity\Schema;
class ColumnDefinition
{
public string $name;
public string $type;
public ?int $length = null;
public ?int $precision = null;
public ?int $scale = null;
public bool $unsigned = false;
public bool $nullable = false;
public mixed $default = null;
public bool $autoIncrement = false;
public function __construct(string $name, string $type)
{
$this->name = $name;
$this->type = $type;
}
public function length(int $length): static { $this->length = $length; return $this; }
public function precision(int $precision, int $scale = 0): static { $this->precision = $precision; $this->scale = $scale; return $this; }
public function unsigned(bool $flag = true): static { $this->unsigned = $flag; return $this; }
public function nullable(bool $flag = true): static { $this->nullable = $flag; return $this; }
public function default(mixed $value): static { $this->default = $value; return $this; }
public function autoIncrement(bool $flag = true): static { $this->autoIncrement = $flag; return $this; }
}

View file

@ -0,0 +1,32 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
abstract class Grammar
{
/**
* Compile a CREATE TABLE statement and any required additional index statements.
* @return array<int,string> SQL statements to execute in order
*/
abstract public function compileCreate(Blueprint $blueprint): array;
/** @return array<int,string> */
abstract public function compileDrop(string $table): array;
/** @return array<int,string> */
abstract public function compileDropIfExists(string $table): array;
/**
* Compile ALTER TABLE statements based on a Blueprint in alter mode.
* @return array<int,string>
*/
abstract public function compileAlter(\Pairity\Schema\Blueprint $blueprint): array;
protected function wrap(string $identifier): string
{
// Default simple wrap with backticks; override in driver if different
return '`' . str_replace('`', '``', $identifier) . '`';
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class MySqlGrammar extends Grammar
{
public function compileCreate(Blueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col);
}
$inline = [];
if ($blueprint->primary) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'UNIQUE' . ($name ? ' ' . $this->wrap($name) : '') . ' (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
// Indexes as separate statements
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
return ['DROP TABLE IF EXISTS ' . $this->wrap($table)];
}
public function compileAlter(\Pairity\Schema\Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
// Add columns
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->compileColumn($col);
}
// Drop columns
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
// Rename columns
foreach ($blueprint->renameColumns as $pair) {
// MySQL 8+: RENAME COLUMN; older: CHANGE old new TYPE ...
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']);
}
// Add uniques
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique');
$stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
// Add indexes
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
// Drop unique/index by name
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP INDEX ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX ' . $this->wrap($n) . ' ON ' . $table;
}
// Rename table
if ($blueprint->renameTo) {
$stmts[] = 'RENAME TABLE ' . $table . ' TO ' . $this->wrap($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c): string
{
$type = match ($c->type) {
'increments' => 'INT',
'bigincrements' => 'BIGINT',
'integer' => 'INT',
'biginteger' => 'BIGINT',
'string' => 'VARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'TEXT',
'boolean' => 'TINYINT(1)',
'json' => 'JSON',
'datetime' => 'DATETIME',
'decimal' => 'DECIMAL(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
if (in_array($c->type, ['integer','biginteger','increments','bigincrements','decimal'], true) && $c->unsigned) {
$parts[] = 'UNSIGNED';
}
if ($c->autoIncrement) {
$parts[] = 'AUTO_INCREMENT';
}
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,153 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class OracleGrammar extends Grammar
{
public function compileCreate(Blueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col);
}
$inline = [];
if ($blueprint->primary) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'CONSTRAINT ' . ($name ? $this->wrap($name) : $this->wrap($this->makeName($blueprint->table, $u['columns'], 'uk'))) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? $this->makeName($blueprint->table, $i['columns'], 'ix');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
// Oracle lacks IF EXISTS; use anonymous PL/SQL block
$tbl = $this->wrap($table);
$plsql = "BEGIN\n EXECUTE IMMEDIATE 'DROP TABLE {$tbl}';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN RAISE; END IF;\nEND;";
return [$plsql];
}
public function compileAlter(Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
// Add columns
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD (' . $this->compileColumn($col) . ')';
}
// Drop columns
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
// Rename columns
foreach ($blueprint->renameColumns as $pair) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']);
}
// Add uniques
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? $this->makeName($blueprint->table, $u['columns'], 'uk');
$stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
// Add indexes
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? $this->makeName($blueprint->table, $i['columns'], 'ix');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
// Drop unique/index by name
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP CONSTRAINT ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX ' . $this->wrap($n);
}
// Rename table
if ($blueprint->renameTo) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME TO ' . $this->wrap($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c): string
{
$type = match ($c->type) {
'increments' => 'NUMBER(10)',
'bigincrements' => 'NUMBER(19)',
'integer' => 'NUMBER(10)',
'biginteger' => 'NUMBER(19)',
'string' => 'VARCHAR2(' . ($c->length ?? 255) . ')',
'text' => 'CLOB',
'boolean' => 'NUMBER(1)', // store 0/1
'json' => 'CLOB', // Oracle JSON type (21c+) not assumed; use CLOB
'datetime' => 'TIMESTAMP',
'decimal' => 'NUMBER(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
protected function wrap(string $identifier): string
{
return '"' . str_replace('"', '""', $identifier) . '"';
}
private function makeName(string $table, array $columns, string $suffix): string
{
$base = $table . '_' . implode('_', $columns) . '_' . $suffix;
// Oracle identifier max length is 30. Shorten deterministically if needed.
if (strlen($base) <= 30) return $base;
$hash = substr(sha1($base), 0, 8);
$short = substr($table, 0, 10) . '_' . substr($columns[0] ?? 'col', 0, 5) . '_' . $suffix . '_' . $hash;
return substr($short, 0, 30);
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class PostgresGrammar extends Grammar
{
public function compileCreate(Blueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col);
}
$inline = [];
if ($blueprint->primary) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'UNIQUE' . ($name ? ' ' . $this->wrap($name) : '') . ' (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
return ['DROP TABLE IF EXISTS ' . $this->wrap($table)];
}
public function compileAlter(Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
// Add columns
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->compileColumn($col);
}
// Drop columns
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
// Rename columns
foreach ($blueprint->renameColumns as $pair) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']);
}
// Add uniques
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique');
$stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
// Add indexes
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
// Drop unique/index by name
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP CONSTRAINT ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX IF EXISTS ' . $this->wrap($n);
}
// Rename table
if ($blueprint->renameTo) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME TO ' . $this->wrap($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c): string
{
$type = match ($c->type) {
'increments' => 'SERIAL',
'bigincrements' => 'BIGSERIAL',
'integer' => 'INTEGER',
'biginteger' => 'BIGINT',
'string' => 'VARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'TEXT',
'boolean' => 'BOOLEAN',
'json' => 'JSONB',
'datetime' => 'TIMESTAMP(0) WITHOUT TIME ZONE',
'decimal' => 'DECIMAL(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? 'TRUE' : 'FALSE';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,145 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class SqlServerGrammar extends Grammar
{
public function compileCreate(BLueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col);
}
$inline = [];
if ($blueprint->primary) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'CONSTRAINT ' . ($name ? $this->wrap($name) : $this->wrap($blueprint->table . '_' . implode('_', $u['columns']) . '_unique')) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
return ['IF OBJECT_ID(N' . $this->quote($table) . ", 'U') IS NOT NULL DROP TABLE " . $this->wrap($table)];
}
public function compileAlter(Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD ' . $this->compileColumn($col);
}
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
foreach ($blueprint->renameColumns as $pair) {
$stmts[] = 'EXEC sp_rename ' . $this->quote($blueprint->table . '.' . $pair['from']) . ', ' . $this->quote($pair['to']) . ', ' . $this->quote('COLUMN');
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique');
$stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP CONSTRAINT ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX ' . $this->wrap($n) . ' ON ' . $table;
}
if ($blueprint->renameTo) {
$stmts[] = 'EXEC sp_rename ' . $this->quote($blueprint->table) . ', ' . $this->quote($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c): string
{
$type = match ($c->type) {
'increments' => 'INT',
'bigincrements' => 'BIGINT',
'integer' => 'INT',
'biginteger' => 'BIGINT',
'string' => 'NVARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'NVARCHAR(MAX)',
'boolean' => 'BIT',
'json' => 'NVARCHAR(MAX)',
'datetime' => 'DATETIME2',
'decimal' => 'DECIMAL(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
if (in_array($c->type, ['integer','biginteger','increments','bigincrements','decimal'], true) && $c->unsigned) {
// SQL Server has no UNSIGNED integers; ignore.
}
if ($c->autoIncrement) {
// IDENTITY(1,1) for auto-increment
$parts[] = 'IDENTITY(1,1)';
}
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
protected function wrap(string $identifier): string
{
return '[' . str_replace([']'], [']]'], $identifier) . ']';
}
private function quote(string $value): string
{
return "'" . str_replace("'", "''", $value) . "'";
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,148 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class SqliteGrammar extends Grammar
{
public function compileCreate(Blueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col, $blueprint);
}
$inline = [];
if ($blueprint->primary) {
// In SQLite, INTEGER PRIMARY KEY on a single column should be on the column itself for autoincrement.
// For composite PKs, use table constraint.
if (count($blueprint->primary) > 1) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'UNIQUE' . ($name ? ' ' . $this->wrap($name) : '') . ' (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
return ['DROP TABLE IF EXISTS ' . $this->wrap($table)];
}
public function compileAlter(\Pairity\Schema\Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
// SQLite supports ADD COLUMN straightforwardly
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->compileColumn($col, $blueprint);
}
// RENAME COLUMN and DROP COLUMN are supported in modern SQLite (3.25+ and 3.35+). We will emit statements; if not supported by the runtime, DB will error.
foreach ($blueprint->renameColumns as $pair) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']);
}
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
// Unique/index operations in SQLite generally require CREATE/DROP INDEX statements
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique');
$stmts[] = 'CREATE UNIQUE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($u['columns']) . ')';
}
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'DROP INDEX IF EXISTS ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX IF EXISTS ' . $this->wrap($n);
}
// Rename table
if ($blueprint->renameTo) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME TO ' . $this->wrap($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c, Blueprint $bp): string
{
// SQLite type affinities
$type = match ($c->type) {
'increments' => 'INTEGER',
'bigincrements' => 'INTEGER',
'integer' => 'INTEGER',
'biginteger' => 'INTEGER',
'string' => 'VARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'TEXT',
'boolean' => 'INTEGER',
'json' => 'TEXT',
'datetime' => 'TEXT',
'decimal' => 'NUMERIC',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
// AUTOINCREMENT style: only valid for a single-column integer primary key
$isPk = (count($bp->primary) === 1 && $bp->primary[0] === $c->name) || ($c->autoIncrement === true);
if ($isPk && in_array($c->type, ['increments','bigincrements','integer','biginteger'], true)) {
$parts[] = 'PRIMARY KEY';
if ($c->autoIncrement) {
$parts[] = 'AUTOINCREMENT';
}
}
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
protected function wrap(string $identifier): string
{
return '"' . str_replace('"', '""', $identifier) . '"';
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Pairity\Schema;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\Grammars\Grammar;
use Pairity\Schema\Grammars\MySqlGrammar;
use Pairity\Schema\Grammars\SqliteGrammar;
use Pairity\Schema\Grammars\PostgresGrammar;
use Pairity\Schema\Grammars\SqlServerGrammar;
use Pairity\Schema\Grammars\OracleGrammar;
use PDO;
final class SchemaManager
{
public static function forConnection(ConnectionInterface $connection): Builder
{
$grammar = self::detectGrammar($connection);
return new Builder($connection, $grammar);
}
private static function detectGrammar(ConnectionInterface $connection): Grammar
{
$native = $connection->getNative();
$driver = null;
if ($native instanceof PDO) {
try {
$driver = $native->getAttribute(PDO::ATTR_DRIVER_NAME);
} catch (\Throwable) {
$driver = null;
}
}
$driver = is_string($driver) ? strtolower($driver) : '';
return match ($driver) {
'sqlite' => new SqliteGrammar(),
'pgsql' => new PostgresGrammar(),
'sqlsrv' => new SqlServerGrammar(),
'oci' => new OracleGrammar(),
'oracle' => new OracleGrammar(),
default => new MySqlGrammar(), // default to MySQL-style grammar
};
}
}

View file

@ -0,0 +1,147 @@
<?php
namespace Pairity\Schema;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\Grammars\SqliteGrammar;
/**
* Best-effort table rebuild strategy for SQLite to emulate unsupported ALTER operations
* (drop column, rename column) across older SQLite versions.
*
* Limitations: complex constraints, triggers, foreign keys, and advanced index options are not preserved.
* Indexes declared in the provided Blueprint (indexes/uniques/dropIndex/dropUnique) will be applied after rebuild.
*/
final class SqliteTableRebuilder
{
public static function rebuild(ConnectionInterface $connection, Blueprint $blueprint, SqliteGrammar $grammar): void
{
$table = $blueprint->table;
// Read existing columns
$columns = $connection->query('PRAGMA table_info(' . self::wrapIdent($table) . ')');
if (!$columns) {
throw new \RuntimeException('Table not found for rebuild: ' . $table);
}
// Build rename map
$renameMap = [];
foreach ($blueprint->renameColumns as $pair) {
$renameMap[$pair['from']] = $pair['to'];
}
$dropSet = array_flip($blueprint->dropColumns);
// Build new column definitions from existing columns (apply drop/rename)
$newCols = [];
$sourceToTarget = [];
foreach ($columns as $col) {
$name = (string)$col['name'];
if (isset($dropSet[$name])) continue; // drop
$targetName = $renameMap[$name] ?? $name;
$type = (string)($col['type'] ?? 'TEXT');
$notnull = ((int)($col['notnull'] ?? 0)) === 1 ? 'NOT NULL' : 'NULL';
$default = $col['dflt_value'] ?? null; // already SQL literal in PRAGMA output
$pk = ((int)($col['pk'] ?? 0)) === 1 ? 'PRIMARY KEY' : '';
$defParts = [self::wrap($targetName), $type, $notnull];
if ($default !== null && $default !== '') {
$defParts[] = 'DEFAULT ' . $default;
}
if ($pk !== '') {
$defParts[] = $pk;
}
$newCols[$targetName] = implode(' ', array_filter($defParts));
$sourceToTarget[$name] = $targetName;
}
// Add newly declared columns from Blueprint (with their definitions via grammar)
foreach ($blueprint->columns as $def) {
$newCols[$def->name] = self::compileColumnSqlite($def, $grammar);
}
// Temp table name
$tmp = $table . '_rebuild_' . substr(sha1((string)microtime(true)), 0, 6);
// Create temp table
$create = 'CREATE TABLE ' . self::wrap($tmp) . ' (' . implode(', ', array_values($newCols)) . ')';
$connection->execute($create);
// Build INSERT INTO tmp (...) SELECT ... FROM table
$targetCols = array_keys($newCols);
$selectExprs = [];
foreach ($targetCols as $colName) {
// If this column existed before, map from old source name (pre-rename), else insert NULL
$sourceName = array_search($colName, $sourceToTarget, true);
if ($sourceName !== false) {
$selectExprs[] = self::wrap($sourceName) . ' AS ' . self::wrap($colName);
} else {
$selectExprs[] = 'NULL AS ' . self::wrap($colName);
}
}
$insert = 'INSERT INTO ' . self::wrap($tmp) . ' (' . self::columnList($targetCols) . ') SELECT ' . implode(', ', $selectExprs) . ' FROM ' . self::wrap($table);
$connection->execute($insert);
// Replace original table
$connection->execute('DROP TABLE ' . self::wrap($table));
$connection->execute('ALTER TABLE ' . self::wrap($tmp) . ' RENAME TO ' . self::wrap($table));
// Apply index/unique operations from the blueprint (post-rebuild)
$post = new Blueprint($table);
// Carry over index ops only
$post->uniques = $blueprint->uniques;
$post->indexes = $blueprint->indexes;
$post->dropUniqueNames = $blueprint->dropUniqueNames;
$post->dropIndexNames = $blueprint->dropIndexNames;
$sqls = $grammar->compileAlter($post);
foreach ($sqls as $sql) {
$connection->execute($sql);
}
}
private static function compileColumnSqlite(ColumnDefinition $c, SqliteGrammar $grammar): string
{
// Minimal re-use: instantiate a throwaway Blueprint to access protected compile via public path is not possible; duplicate minimal mapping here
$type = match ($c->type) {
'increments', 'bigincrements', 'integer', 'biginteger' => 'INTEGER',
'string' => 'VARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'TEXT',
'boolean' => 'INTEGER',
'json' => 'TEXT',
'datetime' => 'TEXT',
'decimal' => 'NUMERIC',
default => strtoupper($c->type),
};
$parts = [self::wrap($c->name), $type];
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . self::quoteDefault($c->default);
}
return implode(' ', $parts);
}
private static function wrap(string $ident): string
{
return '"' . str_replace('"', '""', $ident) . '"';
}
private static function wrapIdent(string $ident): string
{
// For PRAGMA table_info(<name>) we should not quote with double quotes; wrap in simple name if needed
return '"' . str_replace('"', '""', $ident) . '"';
}
private static function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => self::wrap($c), $cols));
}
private static function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

61
tests/MysqlSmokeTest.php Normal file
View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
final class MysqlSmokeTest extends TestCase
{
private function mysqlConfig(): array
{
$host = getenv('MYSQL_HOST') ?: null;
if (!$host) {
$this->markTestSkipped('MYSQL_HOST not set; skipping MySQL smoke test');
}
return [
'driver' => 'mysql',
'host' => $host,
'port' => (int)(getenv('MYSQL_PORT') ?: 3306),
'database' => getenv('MYSQL_DB') ?: 'pairity',
'username' => getenv('MYSQL_USER') ?: 'root',
'password' => getenv('MYSQL_PASS') ?: 'root',
'charset' => 'utf8mb4',
];
}
public function testCreateAndDropTable(): void
{
$cfg = $this->mysqlConfig();
$conn = ConnectionManager::make($cfg);
$schema = SchemaManager::forConnection($conn);
$table = 'pairity_smoke_' . substr(sha1((string)microtime(true)), 0, 6);
$schema->create($table, function (Blueprint $t) {
$t->increments('id');
$t->string('name', 50);
});
$rows = $conn->query('SHOW TABLES LIKE :t', ['t' => $table]);
$this->assertNotEmpty($rows, 'Table should be created');
// Alter add column
$schema->table($table, function (Blueprint $t) {
$t->integer('qty');
});
$cols = $conn->query('SHOW COLUMNS FROM `' . $table . '`');
$names = array_map(fn($r) => $r['Field'] ?? $r['field'] ?? $r['COLUMN_NAME'] ?? '', $cols);
$this->assertContains('qty', $names);
// Drop
$schema->drop($table);
$rows = $conn->query('SHOW TABLES LIKE :t', ['t' => $table]);
$this->assertEmpty($rows, 'Table should be dropped');
}
}

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
final class SchemaBuilderSqliteTest extends TestCase
{
public function testCreateAlterDropCycle(): void
{
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => ':memory:',
]);
$schema = SchemaManager::forConnection($conn);
// Create table
$schema->create('widgets', function (Blueprint $t) {
$t->increments('id');
$t->string('name', 100)->nullable();
$t->integer('qty');
$t->unique(['name'], 'widgets_name_uk');
$t->index(['qty'], 'widgets_qty_idx');
});
// Verify table exists
$tables = $conn->query("SELECT name FROM sqlite_master WHERE type='table' AND name='widgets'");
$this->assertNotEmpty($tables, 'widgets table should exist');
// Alter: add column
$schema->table('widgets', function (Blueprint $t) {
$t->string('desc', 255)->nullable();
});
$cols = $conn->query("PRAGMA table_info('widgets')");
$colNames = array_map(fn($r) => $r['name'], $cols);
$this->assertContains('desc', $colNames);
// Alter: rename column qty -> quantity
$schema->table('widgets', function (Blueprint $t) {
$t->renameColumn('qty', 'quantity');
});
$cols = $conn->query("PRAGMA table_info('widgets')");
$colNames = array_map(fn($r) => $r['name'], $cols);
$this->assertContains('quantity', $colNames);
$this->assertNotContains('qty', $colNames);
// Alter: drop column desc
$schema->table('widgets', function (Blueprint $t) {
$t->dropColumn('desc');
});
$cols = $conn->query("PRAGMA table_info('widgets')");
$colNames = array_map(fn($r) => $r['name'], $cols);
$this->assertNotContains('desc', $colNames);
// Rename table
$schema->table('widgets', function (Blueprint $t) {
$t->rename('widgets_new');
});
$tables = $conn->query("SELECT name FROM sqlite_master WHERE type='table' AND name='widgets_new'");
$this->assertNotEmpty($tables, 'widgets_new table should exist after rename');
// Drop
$schema->drop('widgets_new');
$tables = $conn->query("SELECT name FROM sqlite_master WHERE type='table' AND name='widgets_new'");
$this->assertEmpty($tables, 'widgets_new table should be dropped');
}
}

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
final class SoftDeletesTimestampsSqliteTest extends TestCase
{
private function makeConnection()
{
return ConnectionManager::make([
'driver' => 'sqlite',
'path' => ':memory:',
]);
}
public function testTimestampsAndSoftDeletesFlow(): void
{
$conn = $this->makeConnection();
// Create table
$conn->execute('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
name TEXT NULL,
status TEXT NULL,
created_at TEXT NULL,
updated_at TEXT NULL,
deleted_at TEXT NULL
)');
// Define DTO/DAO
$dto = new class([]) extends AbstractDto {};
$dao = new class($conn) extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return get_class(new class([]) extends AbstractDto {}); }
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'email' => ['cast' => 'string'],
'name' => ['cast' => 'string'],
'status' => ['cast' => 'string'],
'created_at' => ['cast' => 'datetime'],
'updated_at' => ['cast' => 'datetime'],
'deleted_at' => ['cast' => 'datetime'],
],
'timestamps' => ['createdAt' => 'created_at', 'updatedAt' => 'updated_at'],
'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
];
}
};
// Insert (created_at & updated_at auto)
$created = $dao->insert(['email' => 't@example.com', 'name' => 'T', 'status' => 'active']);
$arr = $created->toArray();
$this->assertArrayHasKey('id', $arr);
$this->assertNotEmpty($arr['id']);
$this->assertNotNull($arr['created_at'] ?? null);
$this->assertNotNull($arr['updated_at'] ?? null);
// Update via update() should change updated_at
$id = $arr['id'];
$prevUpdated = $arr['updated_at'];
// sleep(1) not reliable; just ensure it is a value and after call it exists
$dao->update($id, ['name' => 'T2']);
$after = $dao->findById($id)?->toArray();
$this->assertNotNull($after);
$this->assertNotNull($after['updated_at'] ?? null);
// Update via updateBy() also sets updated_at
$dao->updateBy(['id' => $id], ['status' => 'inactive']);
$after2 = $dao->findById($id)?->toArray();
$this->assertEquals('inactive', $after2['status']);
$this->assertNotNull($after2['updated_at'] ?? null);
// Default scope excludes soft-deleted
$dao->deleteById($id);
$list = $dao->findAllBy();
$this->assertCount(0, $list, 'Soft-deleted should be excluded by default');
// withTrashed includes, onlyTrashed returns only deleted
$with = $dao->withTrashed()->findAllBy();
$this->assertCount(1, $with);
$only = $dao->onlyTrashed()->findAllBy();
$this->assertCount(1, $only);
$this->assertNotNull($only[0]->toArray()['deleted_at'] ?? null);
// Restore
$dao->restoreById($id);
$afterRestore = $dao->findById($id);
$this->assertNotNull($afterRestore);
$this->assertNull($afterRestore->toArray()['deleted_at'] ?? null);
// Force delete
$dao->forceDeleteById($id);
$this->assertNull($dao->findById($id));
}
}