From ae80d9bde135081a72263207c36c3abe8abf9368 Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Wed, 10 Dec 2025 07:01:07 -0600 Subject: [PATCH] Initial commit --- .github/workflows/ci.yml | 63 ++ .gitignore | 39 +- README.md | 464 +++++++++++- bin/pairity | 253 +++++++ composer.json | 31 + examples/migrations/AlterUsersAddBio.php | 29 + examples/migrations/CreateUsersTable.php | 30 + examples/mysql_crud.php | 72 ++ examples/run_migrations_sqlite.php | 32 + examples/sqlite_crud.php | 95 +++ phpunit.xml.dist | 17 + src/Contracts/ConnectionInterface.php | 45 ++ src/Contracts/DaoInterface.php | 8 + src/Contracts/DtoInterface.php | 15 + src/Contracts/QueryBuilderInterface.php | 19 + src/Database/ConnectionManager.php | 85 +++ src/Database/PdoConnection.php | 60 ++ src/Migrations/MigrationInterface.php | 11 + src/Migrations/MigrationLoader.php | 38 + src/Migrations/MigrationsRepository.php | 73 ++ src/Migrations/Migrator.php | 103 +++ src/Model/AbstractDao.php | 726 +++++++++++++++++++ src/Model/AbstractDto.php | 69 ++ src/NoSql/Mongo/MongoConnection.php | 105 +++ src/NoSql/Mongo/MongoConnectionInterface.php | 21 + src/Query/QueryBuilder.php | 120 +++ src/Schema/Blueprint.php | 170 +++++ src/Schema/Builder.php | 86 +++ src/Schema/ColumnDefinition.php | 29 + src/Schema/Grammars/Grammar.php | 32 + src/Schema/Grammars/MySqlGrammar.php | 144 ++++ src/Schema/Grammars/OracleGrammar.php | 153 ++++ src/Schema/Grammars/PostgresGrammar.php | 135 ++++ src/Schema/Grammars/SqlServerGrammar.php | 145 ++++ src/Schema/Grammars/SqliteGrammar.php | 148 ++++ src/Schema/SchemaManager.php | 43 ++ src/Schema/SqliteTableRebuilder.php | 147 ++++ tests/MysqlSmokeTest.php | 61 ++ tests/SchemaBuilderSqliteTest.php | 74 ++ tests/SoftDeletesTimestampsSqliteTest.php | 105 +++ 40 files changed, 4056 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 bin/pairity create mode 100644 composer.json create mode 100644 examples/migrations/AlterUsersAddBio.php create mode 100644 examples/migrations/CreateUsersTable.php create mode 100644 examples/mysql_crud.php create mode 100644 examples/run_migrations_sqlite.php create mode 100644 examples/sqlite_crud.php create mode 100644 phpunit.xml.dist create mode 100644 src/Contracts/ConnectionInterface.php create mode 100644 src/Contracts/DaoInterface.php create mode 100644 src/Contracts/DtoInterface.php create mode 100644 src/Contracts/QueryBuilderInterface.php create mode 100644 src/Database/ConnectionManager.php create mode 100644 src/Database/PdoConnection.php create mode 100644 src/Migrations/MigrationInterface.php create mode 100644 src/Migrations/MigrationLoader.php create mode 100644 src/Migrations/MigrationsRepository.php create mode 100644 src/Migrations/Migrator.php create mode 100644 src/Model/AbstractDao.php create mode 100644 src/Model/AbstractDto.php create mode 100644 src/NoSql/Mongo/MongoConnection.php create mode 100644 src/NoSql/Mongo/MongoConnectionInterface.php create mode 100644 src/Query/QueryBuilder.php create mode 100644 src/Schema/Blueprint.php create mode 100644 src/Schema/Builder.php create mode 100644 src/Schema/ColumnDefinition.php create mode 100644 src/Schema/Grammars/Grammar.php create mode 100644 src/Schema/Grammars/MySqlGrammar.php create mode 100644 src/Schema/Grammars/OracleGrammar.php create mode 100644 src/Schema/Grammars/PostgresGrammar.php create mode 100644 src/Schema/Grammars/SqlServerGrammar.php create mode 100644 src/Schema/Grammars/SqliteGrammar.php create mode 100644 src/Schema/SchemaManager.php create mode 100644 src/Schema/SqliteTableRebuilder.php create mode 100644 tests/MysqlSmokeTest.php create mode 100644 tests/SchemaBuilderSqliteTest.php create mode 100644 tests/SoftDeletesTimestampsSqliteTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..75cadcd --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 49a80d2..1a3ca50 100644 --- a/.gitignore +++ b/.gitignore @@ -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 - diff --git a/README.md b/README.md index 5cecd35..f881012 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,465 @@ # Pairity -A partitioned model ORM. Handles DAO and DTO objects \ No newline at end of file +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($value): ?DTO` +- `findAllBy($value): DTO[]` +- `updateBy($value, array $data): int` (returns affected rows) +- `deleteBy($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 + '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) \ No newline at end of file diff --git a/bin/pairity b/bin/pairity new file mode 100644 index 0000000..9266803 --- /dev/null +++ b/bin/pairity @@ -0,0 +1,253 @@ +#!/usr/bin/env php + $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 = <<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' +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); +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2d37d3e --- /dev/null +++ b/composer.json @@ -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" + ] +} diff --git a/examples/migrations/AlterUsersAddBio.php b/examples/migrations/AlterUsersAddBio.php new file mode 100644 index 0000000..0751fd6 --- /dev/null +++ b/examples/migrations/AlterUsersAddBio.php @@ -0,0 +1,29 @@ +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'); + }); + } +}; diff --git a/examples/migrations/CreateUsersTable.php b/examples/migrations/CreateUsersTable.php new file mode 100644 index 0000000..cdc354f --- /dev/null +++ b/examples/migrations/CreateUsersTable.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/examples/mysql_crud.php b/examples/mysql_crud.php new file mode 100644 index 0000000..dc7a3cf --- /dev/null +++ b/examples/mysql_crud.php @@ -0,0 +1,72 @@ + '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; diff --git a/examples/run_migrations_sqlite.php b/examples/run_migrations_sqlite.php new file mode 100644 index 0000000..914c75e --- /dev/null +++ b/examples/run_migrations_sqlite.php @@ -0,0 +1,32 @@ + '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; diff --git a/examples/sqlite_crud.php b/examples/sqlite_crud.php new file mode 100644 index 0000000..631acb7 --- /dev/null +++ b/examples/sqlite_crud.php @@ -0,0 +1,95 @@ + '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"; +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..24b6792 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/Contracts/ConnectionInterface.php b/src/Contracts/ConnectionInterface.php new file mode 100644 index 0000000..cb346f9 --- /dev/null +++ b/src/Contracts/ConnectionInterface.php @@ -0,0 +1,45 @@ + $params + * @return array> + */ + public function query(string $sql, array $params = []): array; + + /** + * Execute a non-SELECT statement (INSERT/UPDATE/DELETE). + * + * @param string $sql + * @param array $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; +} diff --git a/src/Contracts/DaoInterface.php b/src/Contracts/DaoInterface.php new file mode 100644 index 0000000..b55613c --- /dev/null +++ b/src/Contracts/DaoInterface.php @@ -0,0 +1,8 @@ + + */ + public function toArray(bool $deep = true): array; +} diff --git a/src/Contracts/QueryBuilderInterface.php b/src/Contracts/QueryBuilderInterface.php new file mode 100644 index 0000000..0b12830 --- /dev/null +++ b/src/Contracts/QueryBuilderInterface.php @@ -0,0 +1,19 @@ + */ + public function getBindings(): array; +} diff --git a/src/Database/ConnectionManager.php b/src/Database/ConnectionManager.php new file mode 100644 index 0000000..bbc9257 --- /dev/null +++ b/src/Database/ConnectionManager.php @@ -0,0 +1,85 @@ + $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 $config + * @return array{0:string,1:?string,2:?string,3:array} + */ + 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}"); + } + } +} diff --git a/src/Database/PdoConnection.php b/src/Database/PdoConnection.php new file mode 100644 index 0000000..440b812 --- /dev/null +++ b/src/Database/PdoConnection.php @@ -0,0 +1,60 @@ +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; + } + } +} diff --git a/src/Migrations/MigrationInterface.php b/src/Migrations/MigrationInterface.php new file mode 100644 index 0000000..1c919be --- /dev/null +++ b/src/Migrations/MigrationInterface.php @@ -0,0 +1,11 @@ + 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; + } +} diff --git a/src/Migrations/MigrationsRepository.php b/src/Migrations/MigrationsRepository.php new file mode 100644 index 0000000..3e4f3eb --- /dev/null +++ b/src/Migrations/MigrationsRepository.php @@ -0,0 +1,73 @@ +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 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 */ + 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]); + } +} diff --git a/src/Migrations/Migrator.php b/src/Migrations/Migrator.php new file mode 100644 index 0000000..40868f4 --- /dev/null +++ b/src/Migrations/Migrator.php @@ -0,0 +1,103 @@ + */ + 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 $registry + */ + public function setRegistry(array $registry): void + { + $this->registry = $registry; + } + + /** + * Run outstanding migrations. + * + * @param array $migrations An ordered map of name => instance + * @return array 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 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; + } +} diff --git a/src/Model/AbstractDao.php b/src/Model/AbstractDao.php new file mode 100644 index 0000000..4c99cfd --- /dev/null +++ b/src/Model/AbstractDao.php @@ -0,0 +1,726 @@ +|null */ + private ?array $selectedFields = null; + /** @var array> */ + private array $relationFields = []; + /** @var array */ + 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 + */ + abstract protected function dtoClass(): string; + + /** + * Relation metadata to enable eager/lazy loading. + * @return array> + */ + 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 + */ + 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 $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 $criteria + * @return array + */ + 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 $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 $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 $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 $criteria + * @param array $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 $criteria + * @return array{0:string,1:array} + */ + 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 $values + * @return array> + */ + /** + * Fetch related rows where a column is within a set of values. + * Returns DTOs. + * + * @param string $column + * @param array $values + * @param array|null $selectFields If provided, use these fields instead of the DAO's current selection + * @return array + */ + 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('/(?relationFields[$rel][] = $col; + } + } else { + if ($f !== '') { $base[] = $f; } + } + } + if ($base) { + $this->selectedFields = $base; + } else { + $this->selectedFields = $this->selectedFields ?? null; + } + return $this; + } + + /** @param array $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 $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 $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 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 $criteria + * @return array + */ + 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]); + } +} diff --git a/src/Model/AbstractDto.php b/src/Model/AbstractDto.php new file mode 100644 index 0000000..00089a0 --- /dev/null +++ b/src/Model/AbstractDto.php @@ -0,0 +1,69 @@ + */ + protected array $attributes = []; + + /** @param array $attributes */ + public function __construct(array $attributes = []) + { + $this->attributes = $attributes; + } + + /** @param array $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 */ + 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; + } +} diff --git a/src/NoSql/Mongo/MongoConnection.php b/src/NoSql/Mongo/MongoConnection.php new file mode 100644 index 0000000..ca79000 --- /dev/null +++ b/src/NoSql/Mongo/MongoConnection.php @@ -0,0 +1,105 @@ +>>> + * $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 $doc @param array $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)); + } +} diff --git a/src/NoSql/Mongo/MongoConnectionInterface.php b/src/NoSql/Mongo/MongoConnectionInterface.php new file mode 100644 index 0000000..2b84e69 --- /dev/null +++ b/src/NoSql/Mongo/MongoConnectionInterface.php @@ -0,0 +1,21 @@ +> */ + public function find(string $database, string $collection, array $filter = [], array $options = []): iterable; + + /** @param array $document */ + public function insertOne(string $database, string $collection, array $document): string; + + /** @param array $filter @param array $update */ + public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int; + + /** @param array $filter */ + public function deleteOne(string $database, string $collection, array $filter, array $options = []): int; + + /** @param array> $pipeline */ + public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable; +} diff --git a/src/Query/QueryBuilder.php b/src/Query/QueryBuilder.php new file mode 100644 index 0000000..68a351c --- /dev/null +++ b/src/Query/QueryBuilder.php @@ -0,0 +1,120 @@ + */ + 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; + } +} diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php new file mode 100644 index 0000000..647eadf --- /dev/null +++ b/src/Schema/Blueprint.php @@ -0,0 +1,170 @@ + */ + public array $columns = []; + /** @var array */ + public array $primary = []; + /** @var array,name:?string}> */ + public array $uniques = []; + /** @var array,name:?string}> */ + public array $indexes = []; + + // Alter support (MVP) + /** @var array */ + public array $dropColumns = []; + /** @var array */ + public array $renameColumns = []; + public ?string $renameTo = null; + /** @var array */ + public array $dropUniqueNames = []; + /** @var array */ + 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 $columns */ + public function primary(array $columns): void + { + $this->primary = $columns; + } + + /** @param array $columns */ + public function unique(array $columns, ?string $name = null): void + { + $this->uniques[] = ['columns' => $columns, 'name' => $name]; + } + + /** @param array $columns */ + public function index(array $columns, ?string $name = null): void + { + $this->indexes[] = ['columns' => $columns, 'name' => $name]; + } + + // Alter helpers (MVP) + /** @param array $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; + } +} diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php new file mode 100644 index 0000000..7c0fedc --- /dev/null +++ b/src/Schema/Builder.php @@ -0,0 +1,86 @@ +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 $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'; + } + } +} diff --git a/src/Schema/ColumnDefinition.php b/src/Schema/ColumnDefinition.php new file mode 100644 index 0000000..9c99849 --- /dev/null +++ b/src/Schema/ColumnDefinition.php @@ -0,0 +1,29 @@ +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; } +} diff --git a/src/Schema/Grammars/Grammar.php b/src/Schema/Grammars/Grammar.php new file mode 100644 index 0000000..e0fcd61 --- /dev/null +++ b/src/Schema/Grammars/Grammar.php @@ -0,0 +1,32 @@ + SQL statements to execute in order + */ + abstract public function compileCreate(Blueprint $blueprint): array; + + /** @return array */ + abstract public function compileDrop(string $table): array; + + /** @return array */ + abstract public function compileDropIfExists(string $table): array; + + /** + * Compile ALTER TABLE statements based on a Blueprint in alter mode. + * @return array + */ + 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) . '`'; + } +} diff --git a/src/Schema/Grammars/MySqlGrammar.php b/src/Schema/Grammars/MySqlGrammar.php new file mode 100644 index 0000000..e082332 --- /dev/null +++ b/src/Schema/Grammars/MySqlGrammar.php @@ -0,0 +1,144 @@ +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) . "'"; + } +} diff --git a/src/Schema/Grammars/OracleGrammar.php b/src/Schema/Grammars/OracleGrammar.php new file mode 100644 index 0000000..f6a8340 --- /dev/null +++ b/src/Schema/Grammars/OracleGrammar.php @@ -0,0 +1,153 @@ +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) . "'"; + } +} diff --git a/src/Schema/Grammars/PostgresGrammar.php b/src/Schema/Grammars/PostgresGrammar.php new file mode 100644 index 0000000..5debf6a --- /dev/null +++ b/src/Schema/Grammars/PostgresGrammar.php @@ -0,0 +1,135 @@ +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) . "'"; + } +} diff --git a/src/Schema/Grammars/SqlServerGrammar.php b/src/Schema/Grammars/SqlServerGrammar.php new file mode 100644 index 0000000..3981111 --- /dev/null +++ b/src/Schema/Grammars/SqlServerGrammar.php @@ -0,0 +1,145 @@ +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) . "'"; + } +} diff --git a/src/Schema/Grammars/SqliteGrammar.php b/src/Schema/Grammars/SqliteGrammar.php new file mode 100644 index 0000000..7efbe47 --- /dev/null +++ b/src/Schema/Grammars/SqliteGrammar.php @@ -0,0 +1,148 @@ +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) . "'"; + } +} diff --git a/src/Schema/SchemaManager.php b/src/Schema/SchemaManager.php new file mode 100644 index 0000000..3e39989 --- /dev/null +++ b/src/Schema/SchemaManager.php @@ -0,0 +1,43 @@ +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 + }; + } +} diff --git a/src/Schema/SqliteTableRebuilder.php b/src/Schema/SqliteTableRebuilder.php new file mode 100644 index 0000000..f494f54 --- /dev/null +++ b/src/Schema/SqliteTableRebuilder.php @@ -0,0 +1,147 @@ +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() 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) . "'"; + } +} diff --git a/tests/MysqlSmokeTest.php b/tests/MysqlSmokeTest.php new file mode 100644 index 0000000..fb29e26 --- /dev/null +++ b/tests/MysqlSmokeTest.php @@ -0,0 +1,61 @@ +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'); + } +} diff --git a/tests/SchemaBuilderSqliteTest.php b/tests/SchemaBuilderSqliteTest.php new file mode 100644 index 0000000..6164a0a --- /dev/null +++ b/tests/SchemaBuilderSqliteTest.php @@ -0,0 +1,74 @@ + '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'); + } +} diff --git a/tests/SoftDeletesTimestampsSqliteTest.php b/tests/SoftDeletesTimestampsSqliteTest.php new file mode 100644 index 0000000..3ec3c24 --- /dev/null +++ b/tests/SoftDeletesTimestampsSqliteTest.php @@ -0,0 +1,105 @@ + '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)); + } +}