Initial commit
This commit is contained in:
parent
80a640c241
commit
ae80d9bde1
63
.github/workflows/ci.yml
vendored
Normal file
63
.github/workflows/ci.yml
vendored
Normal 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
39
.gitignore
vendored
|
|
@ -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
464
README.md
|
|
@ -1,3 +1,465 @@
|
|||
# Pairity
|
||||
|
||||
A partitioned model ORM. Handles DAO and DTO objects
|
||||
A partitioned‑model 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): table‑focused 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 DAO‑centric: call `with([...])` to eager load; `load()`/`loadMany()` for lazy.
|
||||
- Field projection via `fields('id', 'name', 'posts.title')` with dot‑notation for related selects.
|
||||
- Raw SQL: use `ConnectionInterface::query`, `execute`, `transaction`, `lastInsertId`.
|
||||
- Query Builder: a simple builder (`Pairity\Query\QueryBuilder`) exists for ad‑hoc 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 dot‑notation:
|
||||
|
||||
```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 in‑memory 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 (DAO‑centric 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 join‑based 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 quality‑of‑life 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
253
bin/pairity
Normal 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
31
composer.json
Normal 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"
|
||||
]
|
||||
}
|
||||
29
examples/migrations/AlterUsersAddBio.php
Normal file
29
examples/migrations/AlterUsersAddBio.php
Normal 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');
|
||||
});
|
||||
}
|
||||
};
|
||||
30
examples/migrations/CreateUsersTable.php
Normal file
30
examples/migrations/CreateUsersTable.php
Normal 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
72
examples/mysql_crud.php
Normal 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;
|
||||
32
examples/run_migrations_sqlite.php
Normal file
32
examples/run_migrations_sqlite.php
Normal 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
95
examples/sqlite_crud.php
Normal 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
17
phpunit.xml.dist
Normal 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>
|
||||
45
src/Contracts/ConnectionInterface.php
Normal file
45
src/Contracts/ConnectionInterface.php
Normal 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;
|
||||
}
|
||||
8
src/Contracts/DaoInterface.php
Normal file
8
src/Contracts/DaoInterface.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace Pairity\Contracts;
|
||||
|
||||
interface DaoInterface
|
||||
{
|
||||
public function getTable(): string;
|
||||
}
|
||||
15
src/Contracts/DtoInterface.php
Normal file
15
src/Contracts/DtoInterface.php
Normal 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;
|
||||
}
|
||||
19
src/Contracts/QueryBuilderInterface.php
Normal file
19
src/Contracts/QueryBuilderInterface.php
Normal 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;
|
||||
}
|
||||
85
src/Database/ConnectionManager.php
Normal file
85
src/Database/ConnectionManager.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/Database/PdoConnection.php
Normal file
60
src/Database/PdoConnection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/Migrations/MigrationInterface.php
Normal file
11
src/Migrations/MigrationInterface.php
Normal 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;
|
||||
}
|
||||
38
src/Migrations/MigrationLoader.php
Normal file
38
src/Migrations/MigrationLoader.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/Migrations/MigrationsRepository.php
Normal file
73
src/Migrations/MigrationsRepository.php
Normal 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
103
src/Migrations/Migrator.php
Normal 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
726
src/Model/AbstractDao.php
Normal 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
69
src/Model/AbstractDto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
105
src/NoSql/Mongo/MongoConnection.php
Normal file
105
src/NoSql/Mongo/MongoConnection.php
Normal 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));
|
||||
}
|
||||
}
|
||||
21
src/NoSql/Mongo/MongoConnectionInterface.php
Normal file
21
src/NoSql/Mongo/MongoConnectionInterface.php
Normal 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
120
src/Query/QueryBuilder.php
Normal 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
170
src/Schema/Blueprint.php
Normal 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
86
src/Schema/Builder.php
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/Schema/ColumnDefinition.php
Normal file
29
src/Schema/ColumnDefinition.php
Normal 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; }
|
||||
}
|
||||
32
src/Schema/Grammars/Grammar.php
Normal file
32
src/Schema/Grammars/Grammar.php
Normal 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) . '`';
|
||||
}
|
||||
}
|
||||
144
src/Schema/Grammars/MySqlGrammar.php
Normal file
144
src/Schema/Grammars/MySqlGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
153
src/Schema/Grammars/OracleGrammar.php
Normal file
153
src/Schema/Grammars/OracleGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
135
src/Schema/Grammars/PostgresGrammar.php
Normal file
135
src/Schema/Grammars/PostgresGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
145
src/Schema/Grammars/SqlServerGrammar.php
Normal file
145
src/Schema/Grammars/SqlServerGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
148
src/Schema/Grammars/SqliteGrammar.php
Normal file
148
src/Schema/Grammars/SqliteGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
43
src/Schema/SchemaManager.php
Normal file
43
src/Schema/SchemaManager.php
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
147
src/Schema/SqliteTableRebuilder.php
Normal file
147
src/Schema/SqliteTableRebuilder.php
Normal 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
61
tests/MysqlSmokeTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
74
tests/SchemaBuilderSqliteTest.php
Normal file
74
tests/SchemaBuilderSqliteTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
105
tests/SoftDeletesTimestampsSqliteTest.php
Normal file
105
tests/SoftDeletesTimestampsSqliteTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue