Completely revamped Pairity ORM
Some checks failed
CI / test (8.2) (push) Has been cancelled
CI / test (8.3) (push) Has been cancelled
CI / test (8.4) (push) Has been cancelled

This commit is contained in:
Funky Waddle 2026-02-07 23:26:07 -06:00
parent 9455d31904
commit 1984fbe729
218 changed files with 13173 additions and 10464 deletions

10
.gitattributes vendored Normal file
View file

@ -0,0 +1,10 @@
/tests export-ignore
/.github export-ignore
/examples export-ignore
/.gitignore export-ignore
/.junie export-ignore
/phpunit.xml.dist export-ignore
/composer.lock export-ignore
/CHANGELOG.md export-ignore
/MILESTONES.md export-ignore
/SPECS.md export-ignore

View file

@ -11,42 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
php: [ '8.2', '8.3' ] php: [ '8.2', '8.3', '8.4' ]
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
mongo:
image: mongo:6
ports:
- 27017:27017
options: >-
--health-cmd "mongosh --eval 'db.runCommand({ ping: 1 })' || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 30
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: pairity
POSTGRES_USER: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 20
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -55,56 +20,17 @@ jobs:
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
# Pin ext-mongodb to a 1.21+ line compatible with mongodb/mongodb ^1.20 (which resolves to 1.21+) extensions: pdo, pdo_sqlite, pdo_mysql, pdo_pgsql, pdo_sqlsrv
extensions: pdo, pdo_mysql, pdo_sqlite, pdo_pgsql, mongodb-1.21.0
coverage: none coverage: none
- name: Install dependencies - name: Install dependencies
run: | run: |
composer install --no-interaction --prefer-dist composer install --no-interaction --prefer-dist
- name: Prepare MySQL - name: Initialize Pairity
run: | run: |
sudo apt-get update bin/pairity init
# 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: Prepare Postgres
run: |
for i in {1..30}; do
if pg_isready -h 127.0.0.1 -p 5432 -U postgres; then
break
fi
sleep 2
done
- name: Run tests - name: Run tests
env:
MYSQL_HOST: 127.0.0.1
MYSQL_PORT: 3306
MYSQL_DB: pairity
MYSQL_USER: root
MYSQL_PASS: root
MONGO_HOST: 127.0.0.1
MONGO_PORT: 27017
POSTGRES_HOST: 127.0.0.1
POSTGRES_PORT: 5432
POSTGRES_DB: pairity
POSTGRES_USER: postgres
POSTGRES_PASS: postgres
run: | run: |
vendor/bin/phpunit --colors=always vendor/bin/phpunit --colors=always
- name: Static analysis (PHPStan)
run: |
if [ -f phpstan.neon.dist ]; then vendor/bin/phpstan analyse --no-progress || true; fi
- name: Style check (PHPCS)
run: |
if [ -f phpcs.xml.dist ]; then vendor/bin/phpcs || true; fi

6
.gitignore vendored
View file

@ -12,3 +12,9 @@ crashlytics-build.properties
fabric.properties fabric.properties
.junie/ .junie/
.phpunit.result.cache .phpunit.result.cache
LOG.md
/schema/
/storage/
/src/Models/DTO/
/src/Models/DAO/
/src/Models/Hydrators/

View file

@ -1,34 +1,3 @@
### Changelog ### Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
#### [0.1.1] - 2026-01-07
##### Changed
- **MongoDB Downgrade**: Stepped back MongoDB support from `mongodb/mongodb` ^2.0 to ^1.20 for broader compatibility with older `ext-mongodb` environments.
- **Refactoring**: Simplified `normalizeFilter` in `MongoClientConnection` for better maintainability.
- **Features**: Added `count()` method to `MongoConnectionInterface` and implementations.
#### [0.1.0] - 2026-01-06
##### Added
- **Caching Layer**: Integrated PSR-16 (Simple Cache) support into `AbstractDao` and `AbstractMongoDao`.
- Automated cache invalidation on write operations.
- Identity Map synchronization for cached objects.
- Customizable TTL and prefix per DAO.
- **CLI Enhancements**:
- Extracted CLI logic into dedicated Command classes in `Pairity\Console`.
- Added `--pretend` flag to `migrate`, `rollback`, and `reset` commands for dry-run support.
- Added `--template` flag to `make:migration` for custom migration boilerplate.
- **MongoDB Refinements**:
- Production-ready `MongoClientConnection` wrapping `mongodb/mongodb` ^2.0.
- Integrated caching into `AbstractMongoDao`.
- Improved `_id` normalization and BSON type handling.
- **Improved Testing**: Added `PretendTest`, `MigrationGeneratorTest`, and `CachingTest`.
##### Changed
- Updated `AbstractDto` to support `\Serializable` and PHP 8.1+ `__serialize()`/`__unserialize()` for better cache persistence.
- Refactored `bin/pairity` to use the new Console Command structure.
#### [0.0.1] - 2025-12-11
- Initial development version with core ORM, Relations, Migrations, and basic MongoDB support.

View file

@ -1,62 +1,117 @@
# Milestones # Milestones
## Table of Contents ## Table of Contents
1. [Milestone 1: Core DTO/DAO & Persistence](#milestone-1-core-dtodao--persistence) - [x] Milestone 1: Planning & Specification
2. [Milestone 2: Basic Relations & Eager Loading](#milestone-2-basic-relations--eager-loading) - [x] Milestone 2: CLI Infrastructure & Service Container
3. [Milestone 3: Attribute Accessors/Mutators & Custom Casters](#milestone-3-attribute-accessorsmutators--custom-casters) - [x] Milestone 3: Error Handling & Localization
4. [Milestone 4: Unit of Work & Identity Map](#milestone-4-unit-of-work--identity-map) - [x] Milestone 4: Connection Management & Drivers
5. [Milestone 5: Pagination & Query Scopes](#milestone-5-pagination--query-scopes) - [x] Milestone 5: Advanced Database Features
6. [Milestone 6: Event System](#milestone-6-event-system) - [x] Milestone 6: YAML Schema & Fluent Builder
7. [Milestone 7: Performance Knobs](#milestone-7-performance-knobs) - [x] Milestone 7: Metadata Management & Caching
8. [Milestone 8: Road Ahead](#milestone-8-road-ahead) [x] - [x] Milestone 8: DTO/DAO Code Generation
- [x] Milestone 9: Performance & State Management
- [x] Milestone 10: Basic Query Builder & CRUD
- [x] Milestone 11: Relationships & Eager Loading
- [x] Milestone 12: Unit of Work & Concurrency
- [x] Milestone 13: Lifecycle Events & Auditing
- [x] Milestone 14: Enterprise Features & Tooling
- [x] Milestone 15: Project Clean Up
--- ---
## Milestone 1: Core DTO/DAO & Persistence ## Milestone 1: Planning & Specification
- [x] Basic AbstractDto and AbstractDao - [x] Define Core Concepts and Architecture in SPECS.md
- [x] CRUD operations (Insert, Update, Delete, Find) - [x] Establish CLI Tool as a first-class citizen
- [x] Schema metadata (Casts, Timestamps, Soft Deletes) - [x] Minimal composer.json setup
- [x] Dynamic DAO methods - [x] Project cleanup and reset to PLANNING status
- [x] Basic SQL and SQLite support
## Milestone 2: Basic Relations & Eager Loading ## Milestone 2: CLI Infrastructure & Service Container
- [x] `hasOne`, `hasMany`, `belongsTo` - [x] Implement basic CLI structure (`bin/pairity`)
- [x] Batched eager loading (IN lookup) - [x] Define internal command registration system
- [x] Join-based eager loading (SQL) - [x] Implement Service Container / Dependency Injection base
- [x] Nested eager loading (dot notation) - [x] Verify binary exposure via Composer (`bin` entry in `composer.json`)
- [x] `belongsToMany` and Pivot Helpers
## Milestone 3: Attribute Accessors/Mutators & Custom Casters ## Milestone 3: Error Handling & Localization
- [x] DTO accessors and mutators - [x] Implement Exception Hierarchy
- [x] Pluggable per-column custom casters - [x] Implement i18n Translation Layer for CLI and Exceptions
- [x] Casting integration with hydration and storage
## Milestone 4: Unit of Work & Identity Map ## Milestone 4: Connection Management & Drivers
- [x] Identity Map implementation - [x] Implement `DatabaseManager`
- [x] Deferred mutations (updates/deletes) - [x] Implement Driver abstractions (MySQL, Postgres, SQLite, etc.)
- [x] Transactional/Atomic commits - [x] Support for multiple named connections
- [x] Relation-aware delete cascades - [x] Implement Connection Heartbeat and Health Check logic
- [x] Optimistic Locking (SQL & Mongo)
## Milestone 5: Pagination & Query Scopes ## Milestone 5: Advanced Database Features
- [x] `paginate` and `simplePaginate` for SQL and Mongo - [x] Implement Read/Write splitting logic
- [x] Ad-hoc and Named Query Scopes - [x] Implement Manual Transaction and Savepoint support
- [x] Implement Database Interceptors (Middleware)
## Milestone 6: Event System ## Milestone 6: YAML Schema & Fluent Builder
- [x] Dispatcher and Subscriber interfaces - [x] Define YAML Schema format for table definitions (including Tenancy, Prefix, Inheritance, and Morph)
- [x] DAO lifecycle events - [x] Implement Fluent Schema Builder API
- [x] Unit of Work commit events - [x] Implement YAML parser and validator (supporting Enum casting, attribute visibility, Custom Types, and Encrypted Attributes)
- [x] Implement JSON Schema for YAML autocompletion (Opportunity Radar)
- [x] Implement Schema Linting CLI command (Opportunity Radar)
- [x] Implement Type Mapping Registry (Opportunity Radar)
## Milestone 7: Performance Knobs ## Milestone 7: Metadata Management & Caching
- [x] PDO prepared-statement cache - [x] Implement Metadata Cache (PSR-16 integration)
- [x] Query timing hooks - [x] Support for Database View mapping
- [x] Eager loader IN-batching - [x] Implement Schema Snapshotting (`make:yaml:snapshot`) and Blueprint exporting
- [x] Metadata memoization
## Milestone 8: DTO/DAO Code Generation
- [x] Implement Code Generator for DTOs (supporting serialization, visibility, Virtual Properties, Inheritance, and Encrypted Attributes)
- [x] Implement Code Generator for DAOs
- [x] Implement basic CRUD and Mass Operations (Insert, Update, Delete) in DAOs
## Milestone 9: Performance & State Management
- [x] Implement Pre-Generated Hydrators for performance
- [x] Implement Identity Map pattern for DTO tracking
- [x] Implement Lazy Loading via Ghost Objects (Proxies)
## Milestone 10: Basic Query Builder & CRUD
- [x] Implement fluent Query Builder
- [x] Implement Subquery support in FROM and WHERE IN (Advanced Features)
- [x] Implement Advanced Subquery Features (Joins, Existence Checks, Selects)
- [x] Implement Query Result Normalization (Consistent object syntax for raw results)
- [x] Implement Raw SQL Expressions support
- [x] Implement Driver-Specific Hints and Options
- [x] Implement Query Logging and Profiling
- [x] Implement Standardized Paginator (Offset and Cursor)
- [x] Safety Check: Prevent unconstrained Mass Update/Delete via configuration
## Milestone 11: Relationships & Eager Loading
- [x] Implement Nested, Constrained, and Polymorphic Eager Loading
- [x] Support for Polymorphic relations in DAOs
## Milestone 12: Unit of Work & Concurrency
- [x] Implement Unit of Work for transactional tracking
- [x] Implement Optimistic Locking (versioning)
- [x] Support for Pessimistic Locking (`sharedLock`, `lockForUpdate`)
- [x] Implement Upsert logic
## Milestone 13: Lifecycle Events & Auditing
- [x] Implement Event Dispatcher and lifecycle hooks
- [x] Implement Auditing System
## Milestone 14: Enterprise Features & Tooling
- [x] Implement Automatic Multi-Tenancy scoping
- [x] Implement Data Seeding system
- [x] Implement Procedural Data Migration System (`migration:data`)
- [x] Implement Model Factories
- [x] Implement Seed Squashing (`db:seed:squash`)
- [x] Implement manual migration support
- [x] Implement Schema Introspection (`make:yaml:fromdb`)
- [x] Implement Database Health Check commands (`db:check:health`, `db:check:sync`)
- [x] Implement Query Result Caching
- [x] Implement Async/Parallel Query Execution logic
- [x] Implement Set Operations (Unions, Intersect, Except)
## Milestone 15: Project Clean Up
- [x] Update all thrown exceptions in codebase to use the i18n localization for exception messages
- [x] Exception Context Standardization: Ensure all exceptions leverage the context array in PairityException to pass structured data
- [x] Dead Code Elimination: Identify and remove any unused traits or helper methods
- [x] Create Translation files for Spanish (es.php), French (fr.php), German (de.php), and Italian (it.php)
- [x] Implement Task Priority rule [R21] and Definition of Done guidelines
- [x] Update README to be a normal project type README. Make sure all necessary Features, Installation examples, Usage examples (both DAO/DTO and Query Builder), and anything else typically found in a project README are present.
## Milestone 8: Road Ahead [x]
- [x] Broader Schema Builder ALTER coverage
- [x] More dialect nuances for SQL Server/Oracle
- [x] Enhanced CLI commands
- [x] Caching layer
- [x] Production-ready MongoDB adapter refinements
- [x] Documentation and expanded examples

161
README.md
View file

@ -1,79 +1,138 @@
# Pairity # Pairity ORM
A partitionedmodel PHP ORM (DTO/DAO) with Query Builder, relations, raw SQL helpers, and a portable migrations + schema builder. [![Latest Version on Packagist](https://img.shields.io/packagist/v/getphred/pairity.svg?style=flat-square)](https://packagist.org/packages/getphred/pairity)
[![Total Downloads](https://img.shields.io/packagist/dt/getphred/pairity.svg?style=flat-square)](https://packagist.org/packages/getphred/pairity)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md)
![CI](https://github.com/getphred/pairity/actions/workflows/ci.yml/badge.svg) Pairity is a high-performance, partitioned-model PHP ORM that strictly separates data representation (DTO) from persistence logic (DAO). It provides a fluent Query Builder, robust relationship management, and enterprise-grade features like automatic multi-tenancy, auditing, and transparent attribute encryption.
![Packagist](https://img.shields.io/packagist/v/getphred/pairity.svg)
## Key Features
- **Partitioned Model**: Clean separation between DTOs (Data) and DAOs (Persistence).
- **YAML-Driven Schema**: Define your tables in YAML; generate migrations and models automatically.
- **Fluent Query Builder**: Database-agnostic query construction with support for subqueries, joins, and set operations.
- **Relationships & Eager Loading**: Efficiently handle BelongsTo, HasOne, HasMany, and Polymorphic relations with N+1 prevention.
- **Unit of Work**: Coordinate atomic updates across multiple connections with centralized transaction management.
- **Enterprise Features**:
- **Automatic Multi-Tenancy**: Transparent data isolation via tenant scoping.
- **Auditing**: Automatic change tracking for sensitive models.
- **Concurrency Control**: Built-in Optimistic and Pessimistic locking.
- **Attribute Encryption**: Transparent AES-256 encryption for PII data.
- **Developer Tooling**: First-class CLI (`pairity`) for code generation, migrations, seeding, and health checks.
- **Internationalization (i18n)**: Localized exception messages and CLI output (EN, ES, FR, DE, IT).
## Installation ## Installation
- **Requirements**: PHP >= 8.2, PDO extension for your database(s) Install Pairity via Composer:
- **Install via Composer**:
```bash ```bash
composer require getphred/pairity composer require getphred/pairity
``` ```
After install, you can use the CLI at `vendor/bin/pairity`. Initialize the project structure (creates `schema/`, `src/Models/`, etc.):
## Testing
This project uses PHPUnit 10. The default test suite excludes MongoDB integration tests by default for portability.
- **Install dev dependencies**:
```bash ```bash
composer install vendor/bin/pairity init
```
- **Run the default suite** (SQLite + unit tests; Mongo tests excluded by default):
```bash
vendor/bin/phpunit
```
- **Run MongoDB integration tests** (requires `ext-mongodb >= 1.21` and a reachable server):
```bash
MONGO_HOST=127.0.0.1 MONGO_PORT=27017 vendor/bin/phpunit --group mongo-integration
``` ```
## Quick Start ## Quick Start
Minimal example with SQLite and a simple `users` DAO/DTO. ### 1. Define your Schema
```php Create a YAML file in `schema/users.yaml` (Note: the `schema/` directory is automatically created by the `init` command):
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
// 1) Connect ```yaml
$conn = ConnectionManager::make([ columns:
'driver' => 'sqlite', id:
'path' => __DIR__ . '/db.sqlite', type: bigIncrements
]); email:
type: string
unique: true
password:
type: string
encrypted: true
active:
type: boolean
default: true
// 2) Define DTO + DAO relations:
class UserDto extends AbstractDto {} posts:
class UserDao extends AbstractDao { type: hasMany
public function getTable(): string { return 'users'; } target: posts
protected function dtoClass(): string { return UserDto::class; }
}
// 3) CRUD
$dao = new UserDao($conn);
$created = $dao->insert(['email' => 'a@b.com', 'name' => 'Alice']);
$user = $dao->findById($created->id);
``` ```
For more detailed usage and technical specifications, see [SPECS.md](SPECS.md). ### 2. Generate Models
```bash
vendor/bin/pairity make:model
```
### 3. Run Migrations
```bash
vendor/bin/pairity migrate
```
### 4. Usage
#### Basic CRUD
```php
use App\Models\DTO\UsersDTO;
use App\Models\DAO\UsersDAO;
$userDao = $container->get(UsersDAO::class);
// Create
$user = new UsersDTO(['email' => 'jane@example.com', 'password' => 'secret']);
$userDao->save($user);
// Read
$user = $userDao->find(1);
echo $user->email;
// Update
$user->setAttribute('active', false);
$userDao->save($user);
```
#### Query Builder
```php
$users = $userDao->query()
->where('active', true)
->whereIn('role', ['admin', 'editor'])
->orderBy('created_at', 'desc')
->limit(10)
->get();
```
#### Eager Loading
```php
$users = $userDao->query()
->with('posts.comments')
->get();
foreach ($users as $user) {
foreach ($user->posts as $post) {
// Posts are already loaded!
}
}
```
## Documentation ## Documentation
- [Specifications](SPECS.md)
- [Milestones & Roadmap](MILESTONES.md)
- [Examples](examples/)
## Contributing For detailed documentation, please refer to the [SPECS.md](SPECS.md) file.
This is an early foundation. Contributions, discussions, and design proposals are welcome. Please open an issue to coordinate larger features. ## Testing
Run the test suite using PHPUnit:
```bash
vendor/bin/phpunit
```
## License ## License
MIT The MIT License (MIT). Please see [LICENSE.md](LICENSE.md) for more information.

287
SPECS.md
View file

@ -1,7 +1,105 @@
# Pairity Specifications # Pairity Specifications
**Status**: IMPLEMENTATION
## Architecture ## Architecture
Pairity is a partitionedmodel PHP ORM (DTO/DAO) that separates data representation (DTO) from persistence logic (DAO). It includes a Query Builder, relation management, raw SQL helpers, and a portable migrations + schema builder. Pairity is a partitionedmodel PHP ORM (DTO/DAO) that separates data representation (DTO) from persistence logic (DAO). It includes a Query Builder, relation management, raw SQL helpers, and a user friendly migration system.
- **Current DB Support**:
- MySQL/MariaDB
- PostgreSQL
- SQLite
- SQL Server
- Oracle
- **Table Definitions**
- Create YAML file to create new table.
- file names are used as table names.
- `relations` key in the YAML file can be used to define relationships.
- `validation` key in the YAML file can be used to define validation rules.
- `indexes` key in the YAML file can be used to define indexes.
- `primary` key can define composite primary keys.
- `prefix` can define table-specific prefixes.
- `tenancy` key to enable automatic tenant scoping.
- `inheritance` key to define Single Table (STI) or Class Table (CTI) inheritance.
- `morph` key to define polymorphic relationship targets.
- `encrypted` flag on columns for transparent AES-256 encryption.
- `auditable` key to enable automatic change tracking/auditing.
- `timestamps` key in the YAML file can be used to enable timestamps.
- `softDeletes` key in the YAML file can be used to enable soft deletes.
- Support for **Database Views** (mapping DTOs to SQL views).
- Update YAML file to modify existing tables.
- Run `pairity migrate` to apply changes.
- Run `pairity make:model` to generate DTO and DAO classes from YAML files.
- **Schema Builder**:
- Fluent PHP API for programmatic table creation and alteration.
- Acts as the underlying engine for YAML-driven migrations.
- Support for **Schema Blueprints** (PHP-based schema snapshots).
- Support for **Schema Snapshotting** (`make:yaml:snapshot`) for baseline deployments.
- Support for **Dry Run** previews via `migrate:dryrun`.
- **Auditing & Change Tracking**
- Built-in auditing system that automatically tracks changes to DTOs.
- Enabled via `auditable: true` in YAML schema.
- Records previous and current values, event type, and timestamps.
- Integration with `AuditListener` and `Auditor` service.
- **Event Dispatcher & Lifecycle Hooks**
- Robust event system for model lifecycle: `retrieved`, `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`.
- Supports halting operations (e.g., validation) via "before" events.
- Wildcard listener support (e.g., `pairity.model.*`).
- **Models**:
- **DTOs / Data Transfer Objects**:
- immutable by default.
- Supports accessors and mutators.
- Supports validation.
- Supports casting (including native PHP Enums, **Custom Cast Types**, and **Encrypted Attributes**).
- Supports relations (including **Lazy Loading via Ghost Objects** and **Polymorphism**).
- Supports serialization (`toArray`, `toJson`).
- Supports hidden/exposed attributes for serialization.
- Supports **Virtual/Computed Properties**.
- Supports **Inheritance Mapping** (STI/CTI).
- Managed via **Identity Map**.
- Performance boosted by **Pre-Generated Hydrators**.
- **DAOs / Data Access Objects**:
- Provide CRUD and **Mass Operations** (Insert, Update, Delete).
- Support for **Upserts** (Insert or Update).
- Support for relations (including **Polymorphic relations**).
- Support for dynamic helpers.
- Support for Scopes (including Global Scopes, Soft Deletes, **Automatic Multi-Tenancy**, and **Polymorphic Scopes**).
- Support for Unit of Work (with **Automatic Auditing**).
- Support for Event System.
- Support for Migrations.
- Support for Schema Builder.
- Returns DTO, **Pairity Collection**, or **Pairity Paginator**.
- **Query Builder**:
- Supports raw SQL.
- Supports joins.
- Supports eager loading (Nested, Constrained, and **Polymorphic**).
- Supports pagination (Offset and Cursor).
- Supports **Concurrency Control** (Shared and Exclusive Locks).
- Supports **Query Result Caching**.
- Supports **Async/Parallel Query Execution**.
- Supports scopes.
- Supports casts.
- Supports custom helpers.
- Supports **Read/Write Splitting**.
- Supports **Query Logging & Profiling**.
- Supports **Set Operations** (Unions, Intersect, Except).
- Supports **Driver-Specific Hints & Options**.
- DOES NOT support NoSQL.
- **Framework Integration**:
- **Connection Management**:
- Multi-database support with named connections.
- Explicit transaction control (`beginTransaction`, `commit`, `rollBack`).
- Support for **Savepoints** (nested transactions).
- Support for **Connection Heartbeats** and health checks via `db:check:health`.
- Support for **Database Interceptors (Middleware)**.
- **Seeding & Factories**: Built-in support for dummy data and test state generation.
- **Localization (i18n)**:
- Environment-driven language selection (`PAIRITY_LOCALE`).
- Localized exception messages and CLI output.
- **Metadata Caching**: Caching of hydrated schema to avoid repeated YAML parsing.
- **Service Provider**: Standardized entry point for framework integration.
### Namespace ### Namespace
`Pairity\` `Pairity\`
@ -11,115 +109,110 @@ Pairity is a partitionedmodel PHP ORM (DTO/DAO) that separates data represent
## Core Concepts ## Core Concepts
### DTO (Data Transfer Object) ### 1. Partitioned Model (DTO/DAO Separation)
A lightweight data bag. Pairity strictly separates data state from persistence logic.
- Extend `Pairity\Model\AbstractDto`. - **DTO (Data Transfer Object)**: Pure, immutable (by default) data carriers representing a single record. They are responsible for data integrity, casting, and validation, but have no knowledge of the database.
- Convert to arrays via `toArray(bool $deep = true)`. - **DAO (Data Access Object)**: The persistence engine for a model. DAOs handle all database communication (CRUD, queries) and act as factories that produce DTOs.
- Support for accessors and mutators.
### DAO (Data Access Object) ### 2. YAML-Driven Schema
Tablefocused persistence and relations. The database schema is defined in YAML files, which serve as the single source of truth. These files are used to:
- Extend `Pairity\Model\AbstractDao`. - Generate and execute migrations.
- Required implementations: - Generate DTO and DAO class code to ensure type safety and consistency.
- `getTable(): string`
- `dtoClass(): string` (class-string of the DTO)
- Optional implementations:
- `schema()`: for casts, timestamps, soft deletes, and locking.
- `relations()`: for defining `hasOne`, `hasMany`, `belongsTo`, and `belongsToMany`.
## Features ### 3. Unified Query Builder
A fluent PHP interface for constructing SQL queries that works across all supported relational databases. It abstracts driver-specific syntax while allowing raw SQL for complex, non-portable optimizations.
### Persistence & CRUD ### 4. Unit of Work
- `insert(array $data)`: Immediate execution to return real IDs. The Unit of Work tracks all changes made to DTOs during a business transaction. It coordinates the writing out of these changes and resolves concurrency issues, ensuring that the database remains in a consistent state.
- `update($id, array $data)`: Updates by primary key.
- `updateBy(array $criteria, array $data)`: Bulk updates.
- `deleteById($id)`: Deletes by primary key (supports soft deletes).
- `deleteBy(array $criteria)`: Bulk deletes (supports soft deletes).
- `findById($id)`: Find a single DTO by ID.
- `findOneBy(array $criteria)`: Find a single DTO by criteria.
- `findAllBy(array $criteria)`: Find all matching DTOs.
### Dynamic DAO Methods ### 5. Event-Driven Architecture
`AbstractDao` supports dynamic helpers mapped to column names (Studly/camel to snake_case): A robust event system that allows developers to hook into the lifecycle of DTOs and DAOs (e.g., `beforeSave`, `afterUpdate`, `onDelete`). This facilitates decoupled logic for auditing, cache invalidation, and complex validation.
- `findOneBy<Column>($value): ?DTO`
- `findAllBy<Column>($value): DTO[]`
- `updateBy<Column>($value, array $data): int`
- `deleteBy<Column>($value): int`
### Projection ### 6. CLI Tool (`pairity`)
- Default projection is `SELECT *`. The `pairity` CLI is a first-class citizen of the framework, providing essential developer tools for schema management, code generation, and maintenance. It includes specialized commands for schema evolution (`migrate:dryrun`), availability (`db:check:health`), and state synchronization (`db:check:sync`).
- Use `fields(...$fields)` to limit columns. Supports dot-notation for related selects (e.g., `posts.title`).
- `fields()` affects only the next find call and then resets.
### Relations ### 7. Identity Map
- Relation types: `hasOne`, `hasMany`, `belongsTo`, `belongsToMany`. To maintain data consistency, Pairity uses an Identity Map to track all instantiated DTOs within a single request cycle. This ensures that fetching the same record multiple times returns the same object instance.
- **Eager Loading**:
- Default: Batched queries using `IN (...)` lookups.
- Opt-in: Join-based (`useJoinEager()`) for single-level SQL relations.
- Nested: Supported via dot notation (e.g., `posts.comments`).
- **Lazy Loading**: Via `load()` or `loadMany()` methods.
- **Cascades**: `cascadeDelete` supported for `hasOne` and `hasMany` within a Unit of Work.
- **Pivot Helpers**: `attach`, `detach`, and `sync` for `belongsToMany`.
### Model Metadata & Schema Mapping ### 8. Connection Management
Defined via `schema()` method in DAO. A centralized `DatabaseManager` handles multiple PDO connections, allowing the ORM to communicate with different databases or read/write replicas seamlessly.
- **Casting**: `int`, `float`, `bool`, `string`, `datetime`, `json`, or custom `CasterInterface`.
- **Timestamps**: `createdAt` and `updatedAt` filled automatically. Uses UTC `Y-m-d H:i:s`.
- **Soft Deletes**:
- Enabled via `softDeletes` configuration.
- Query scopes: `withTrashed()`, `onlyTrashed()`.
- Helpers: `restoreById()`, `forceDeleteById()`.
- **Locking**: Optimistic locking supported via `version` or `timestamp` strategies.
### Unit of Work (UoW) ### 9. Metadata Caching
Optional batching and identity map. To optimize performance, Pairity caches the processed YAML schema and table metadata using a PSR-16 cache. This minimizes filesystem I/O and YAML parsing during runtime.
- Identity Map: Same DTO instance per `[DAO + ID]` within the UoW scope.
- Deferred mutations: `update` and `delete` are queued until commit.
- Atomicity: Transactional commit per connection.
### Event System ### 10. Data Seeding & Factories
Lightweight hook system for DAO operations and UoW commits. Pairity provides a robust system for seeding the database with initial data and generating complex DTO states via Factories, facilitating both development and testing.
- Events: `dao.before*`, `dao.after*`, `uow.beforeCommit`, `uow.afterCommit`.
- Dispatcher and Subscriber interfaces.
### Pagination ### 11. Query Logging & Profiling
- `paginate(page, perPage, criteria)`: Returns data with total, lastPage, etc. A centralized logging mechanism to capture all executed SQL queries, their execution time, and bound parameters. This is essential for debugging, performance optimization, and security audits.
- `simplePaginate(page, perPage, criteria)`: Returns data without total (uses nextPage detection).
### Query Scopes ### 12. Standardized Pagination
- Ad-hoc: `scope(callable $fn)`. Pairity provides a dedicated `Paginator` object for paginated results. This object encapsulates the DTO collection along with metadata like total records, current page, and navigation helpers, ensuring a consistent API response.
- Named: Registered via `registerScope()`.
## Databases ### 13. Structured Exception Hierarchy
Pairity uses a custom exception hierarchy to provide clear, actionable error feedback. This avoids leaking raw database driver exceptions and allows developers to handle specific scenarios (e.g., `RecordNotFoundException`, `QueryException`) gracefully.
### Supported SQL ### 14. Localization (i18n)
- MySQL / MariaDB To support global development, Pairity includes a localization layer. Exception messages and CLI output are translated based on the environment configuration (`PAIRITY_LOCALE`), facilitating better accessibility and debugging for developers across different regions.
- SQLite (including table rebuild fallback for legacy versions)
- PostgreSQL
- SQL Server
- Oracle
### NoSQL (MongoDB) ### 15. DTO Serialization & Transformation
- Production adapter: Wraps `mongodb/mongodb` library. Pairity DTOs provide built-in support for transformation into array or JSON formats, making them ideal for use in modern APIs. This includes the ability to define "hidden" fields (e.g., sensitive data) that are excluded by default, and "exposed" fields that are included during serialization.
- Stub adapter: In-memory for experimentation.
- Supports aggregation pipelines, pagination, and optimistic locking.
## Migrations & Schema Builder ### 16. Concurrency Control (Locking)
Lightweight runner and portable builder. To handle high-concurrency environments, Pairity provides both Optimistic and Pessimistic locking.
- Operations: `create`, `drop`, `dropIfExists`, `table` (ALTER). - **Optimistic Locking**: Automatic versioning via a `version` column.
- Column types: `increments`, `string`, `text`, `integer`, `boolean`, `json`, `datetime`, `decimal`, `timestamps`. - **Pessimistic Locking**: Support for Shared (Read) and Exclusive (Write) locks via the Query Builder (`sharedLock()`, `lockForUpdate()`).
- Indexing: `primary`, `unique`, `index`.
- CLI: `pairity` binary for `migrate`, `rollback`, `status`, `reset`, `make:migration`.
- Supports `--pretend` flag for dry-runs (migrate, rollback, reset).
- Supports `--template` flag for custom migration templates (make:migration).
## Performance ### 17. Multi-Tenancy & Data Isolation
- PDO prepared-statement cache (LRU). Pairity supports enterprise-grade multi-tenancy through automatic scoping. By defining a tenant identifier in the YAML schema, the ORM automatically injects the necessary filters into all queries and inserts, ensuring strict data isolation between tenants with zero developer overhead.
- Query timing hooks.
- Eager loader IN-batching. ### 18. Performance Optimization (Hydrators & Ghost Objects)
- Metadata memoization. To achieve maximum performance, Pairity employs advanced optimization techniques:
- **Caching Layer**: PSR-16 (Simple Cache) integration for DAO-level caching. - **Pre-Generated Hydrators**: Code-generated classes specialized for populating DTOs, bypassing slow reflection-based instantiation.
- Optional per-DAO cache configuration. - **Lazy Loading via Ghost Objects**: Utilizing the Proxy pattern to instantiate DTOs for relations that only trigger a database query when a property is accessed.
- Automatic invalidation on write operations.
- Identity Map synchronization for cached DTOs. ### 19. Custom Extension & Interceptors
- Support for bulk invalidation (configurable). Developers can extend the ORM's core behavior through:
- **Custom Cast Types**: Defining reusable casting logic for unique data types (e.g., encryption, spatial data).
- **Database Interceptors (Middleware)**: Hooking into the `DatabaseManager` to intercept and modify raw PDO calls for auditing, security, or custom logging.
- **Virtual Properties**: Defining computed DTO attributes in YAML that behave as first-class fields.
### 20. Data Migrations & Procedural Transforms
Beyond schema changes, Pairity supports procedural data migrations. These are PHP-based scripts used to transform or move data using the ORM's business logic, ensuring complex data transitions are handled safely within the application's domain layer.
### 21. Polymorphic Relationships
Pairity allows a model to belong to more than one other type of model on a single association. This is implemented via `morphTo`, `morphOne`, and `morphMany`, allowing for flexible data structures like shared comment or attachment systems.
### 22. Table Inheritance Mapping
To support domain-driven design, Pairity provides two primary inheritance patterns:
- **Single Table Inheritance (STI)**: Mapping multiple DTO types to a single database table using a discriminator column.
- **Class Table Inheritance (CTI)**: Mapping a class hierarchy across multiple tables, with a base table for common attributes and specialized tables for extended data.
### 23. Auditing & Change Tracking
Pairity includes a built-in auditing system that automatically tracks changes to DTOs. When enabled in the YAML schema, the ORM records the previous and current values of attributes, the timestamp, and the user responsible for the change, facilitating compliance and security auditing.
### 24. Async & Parallel Query Execution
For high-performance applications using PHP Fibers or asynchronous runtimes (e.g., Swoole, RoadRunner), Pairity supports dispatching multiple independent queries in parallel. This maximizes database throughput and reduces total response time for data-heavy operations.
### 25. Attribute Encryption
Pairity provides native support for transparent attribute encryption. Sensitive data (PII) can be marked as `encrypted` in the YAML schema, and the ORM will automatically handle AES-256 encryption/decryption before storage and after retrieval, ensuring data-at-rest security.
### 26. Seed Squashing
To facilitate rapid environment setup, Pairity supports squashing individual seed files into a single, optimized baseline seed. This reduces the overhead of executing hundreds of separate seeder classes and provides a clean starting point for new development or staging environments.
## CLI Actions
List of actions that the `pairity` tool can perform:
- `db:check:health`: Verify database connection health and heartbeat.
- `db:check:sync`: Verify synchronization of manual migration files and seed files (excludes YAML to DB direct).
- `db:seed`: Seed the database with records.
- `db:seed:squash`: Squash all individual seed files into a single baseline seed for new environments.
- `make:factory`: Create a new Factory class for a model.
- `make:migration`: Create a manual migration file for custom SQL or data changes.
- `make:model`: Generate DTO and DAO classes from YAML schema definitions.
- `make:seeder`: Create a new Seeder class.
- `make:yaml:fromdb`: Reverse-engineer an existing database to generate YAML schema files.
- `make:yaml:snapshot`: Export the current YAML source of truth into a single SQL or PHP baseline snapshot.
- `migrate`: Apply pending schema changes defined in YAML files.
- `migrate:dryrun`: Preview all changes that would be run with `migrate` (YAML to DB direct only).
- `migrate:rollback`: Roll back the last database migration.
- `migration:data`: Execute procedural PHP data migrations.

189
bin/pairity Normal file → Executable file
View file

@ -3,86 +3,117 @@
declare(strict_types=1); declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php'; /**
* Pairity CLI Entry Point
*
* This script bootstraps the Pairity ORM CLI environment,
* initializes the service container, and runs the console application.
*/
use Pairity\Console\MigrateCommand; // Search for the autoloader.
use Pairity\Console\RollbackCommand; $autoloadPath = __DIR__ . '/../vendor/autoload.php';
use Pairity\Console\StatusCommand;
use Pairity\Console\ResetCommand;
use Pairity\Console\MakeMigrationCommand;
use Pairity\Console\MongoIndexEnsureCommand;
use Pairity\Console\MongoIndexDropCommand;
use Pairity\Console\MongoIndexListCommand;
function parseArgs(array $argv): array { if (!file_exists($autoloadPath)) {
$args = ['_cmd' => $argv[1] ?? 'help']; // Translate if possible, otherwise show English
for ($i = 2; $i < count($argv); $i++) { $msg = "Composer autoloader not found. Please run 'composer install'.";
$a = $argv[$i]; fwrite(STDERR, $msg . "\n");
if (str_starts_with($a, '--')) { exit(1);
$eq = strpos($a, '='); }
if ($eq !== false) {
$key = substr($a, 2, $eq - 2); require $autoloadPath;
$val = substr($a, $eq + 1);
$args[$key] = $val; use Pairity\Container\Container;
} else { use Pairity\Console\Application;
$key = substr($a, 2); use Pairity\Contracts\Translation\TranslatorInterface;
$args[$key] = true; use Pairity\Translation\Translator;
} use Pairity\Contracts\Database\DatabaseManagerInterface;
} else { use Pairity\Database\DatabaseManager;
$args[] = $a; use Pairity\Console\Commands\DatabaseHealthCheckCommand;
}
} try {
return $args; // 1. Initialize the Service Container.
} $container = new Container();
function cmd_help(): void // 1.0 Register Identity Map (singleton).
{ $container->singleton(\Pairity\DTO\IdentityMap::class);
$help = <<<TXT
Pairity CLI // 1.0.1 Register Proxy Factory (singleton).
$container->singleton(\Pairity\DTO\ProxyFactory::class);
Usage:
pairity migrate [--path=DIR] [--config=FILE] [--pretend] // 1.1 Register Translator service (singleton) with path to translations.
pairity rollback [--steps=N] [--config=FILE] [--pretend] $container->singleton(TranslatorInterface::class, function ($c) {
pairity status [--path=DIR] [--config=FILE] return new Translator(__DIR__ . '/../src/Translations');
pairity reset [--config=FILE] [--pretend] });
pairity make:migration Name [--path=DIR] [--template=FILE]
pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique] // 1.2 Register Database Manager (singleton).
pairity mongo:index:drop DB COLLECTION NAME $container->singleton(DatabaseManagerInterface::class, function ($c) {
pairity mongo:index:list DB COLLECTION // In a real app, this config would come from a file.
$config = [
Environment: 'default' => 'sqlite',
DB_DRIVER, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PATH (for sqlite) 'connections' => [
'sqlite' => [
If --config is provided, it must be a PHP file returning the ConnectionManager config array. 'driver' => 'sqlite',
TXT; 'database' => ':memory:',
echo $help . PHP_EOL; ],
} ],
];
$args = parseArgs($argv); return new DatabaseManager($config);
$cmd = $args['_cmd'] ?? 'help'; });
$commands = [ // 1.3 Register Cache and Metadata services.
'migrate' => MigrateCommand::class, $container->singleton(\Psr\SimpleCache\CacheInterface::class, function ($c) {
'rollback' => RollbackCommand::class, return new \Pairity\Cache\FileCache(__DIR__ . '/../storage/cache');
'status' => StatusCommand::class, });
'reset' => ResetCommand::class,
'make:migration' => MakeMigrationCommand::class, $container->singleton(\Pairity\Schema\MetadataManager::class, function ($c) {
'mongo:index:ensure' => MongoIndexEnsureCommand::class, return new \Pairity\Schema\MetadataManager(
'mongo:index:drop' => MongoIndexDropCommand::class, $c->make(\Pairity\Schema\YamlSchemaParser::class),
'mongo:index:list' => MongoIndexListCommand::class, $c->make(\Psr\SimpleCache\CacheInterface::class)
]; );
});
if ($cmd === 'help' || !isset($commands[$cmd])) {
cmd_help(); $container->singleton(\Pairity\Schema\CodeGenerator::class, function ($c) {
exit($cmd === 'help' ? 0 : 1); return new \Pairity\Schema\CodeGenerator(__DIR__ . '/../src/Stubs');
} });
try { // 2. Initialize the Console Application.
$class = $commands[$cmd]; $app = new Application($container);
/** @var \Pairity\Console\CommandInterface $instance */
$instance = new $class(); // 3. Register Core Commands.
$instance->execute($args); $app->add(\Pairity\Console\Commands\InitCommand::class);
} catch (\Throwable $e) { $app->add(DatabaseHealthCheckCommand::class);
fwrite(STDERR, 'Error: ' . $e->getMessage() . PHP_EOL); $app->add(\Pairity\Console\Commands\SyncCheckCommand::class);
$app->add(\Pairity\Console\Commands\GenerateJsonSchemaCommand::class);
$app->add(\Pairity\Console\Commands\SchemaLintCommand::class);
$app->add(\Pairity\Console\Commands\GenerateSchemaSnapshotCommand::class);
$app->add(\Pairity\Console\Commands\GenerateModelsCommand::class);
$app->add(\Pairity\Console\Commands\SeedCommand::class);
$app->add(\Pairity\Console\Commands\MakeSeederCommand::class);
$app->add(\Pairity\Console\Commands\MakeFactoryCommand::class);
$app->add(\Pairity\Console\Commands\MakeMigrationCommand::class);
$app->add(\Pairity\Console\Commands\RunDataMigrationCommand::class);
$app->add(\Pairity\Console\Commands\IntrospectCommand::class);
$app->add(\Pairity\Console\Commands\CacheClearCommand::class);
// $app->add(\Pairity\Console\Commands\MigrateCommand::class);
// 4. Run the Application.
$exitCode = $app->run($_SERVER['argv']);
exit($exitCode);
} catch (\Throwable $e) {
// Try to use translator if available
$message = 'Uncaught Exception: ' . $e->getMessage();
try {
if (isset($container) && $container->has(TranslatorInterface::class)) {
/** @var TranslatorInterface $t */
$t = $container->get(TranslatorInterface::class);
$message = $t->trans('error.uncaught_exception', ['message' => $e->getMessage()]);
}
} catch (\Throwable $ignored) {
// ignore translation errors in emergency handler
}
fwrite(STDERR, $message . "\n");
fwrite(STDERR, $e->getTraceAsString() . "\n");
exit(1); exit(1);
} }

View file

@ -1,8 +1,11 @@
{ {
"name": "getphred/pairity", "name": "getphred/pairity",
"description": "DAO/DTO-centric PHP ORM with Query Builder, relations (SQL + MongoDB), migrations/CLI, Unit of Work, and event system. Supports MySQL/MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, and MongoDB.", "description": "DAO/DTO-centric PHP ORM with Query Builder, relations, migrations/CLI, Unit of Work, and event system. Supports MySQL/MariaDB, PostgreSQL, SQLite, SQL Server, and Oracle.",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"bin": [
"bin/pairity"
],
"authors": [ "authors": [
{ {
"name": "Phred", "name": "Phred",
@ -29,8 +32,9 @@
"sqlite", "sqlite",
"sqlserver", "sqlserver",
"oracle", "oracle",
"mongodb", "multi-tenancy",
"nosql" "concurrency",
"performance"
], ],
"homepage": "https://github.com/getphred/pairity", "homepage": "https://github.com/getphred/pairity",
"support": { "support": {
@ -39,13 +43,14 @@
}, },
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-mongodb": "^1.21", "psr/simple-cache": "^3.0",
"mongodb/mongodb": "^1.21", "symfony/yaml": "^8.0",
"psr/simple-cache": "^3.0" "ext-pdo": "*"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Pairity\\": "src/" "Pairity\\": "src/",
"App\\": "src/"
} }
}, },
"autoload-dev": { "autoload-dev": {
@ -56,10 +61,14 @@
"require-dev": { "require-dev": {
"phpunit/phpunit": "^10.5" "phpunit/phpunit": "^10.5"
}, },
"scripts": {
"post-install-cmd": [
"bin/pairity init"
],
"post-update-cmd": [
"bin/pairity init"
]
},
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true "prefer-stable": true
,
"bin": [
"bin/pairity"
]
} }

314
composer.lock generated
View file

@ -4,134 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4b460634f481dac571848ddf91d287ae", "content-hash": "82d818d72ff2ec7c8196017a7ac8d14d",
"packages": [ "packages": [
{
"name": "mongodb/mongodb",
"version": "1.21.3",
"source": {
"type": "git",
"url": "https://github.com/mongodb/mongo-php-library.git",
"reference": "b8f569ec52542d2f1bfca88286f20d14a7f72536"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/b8f569ec52542d2f1bfca88286f20d14a7f72536",
"reference": "b8f569ec52542d2f1bfca88286f20d14a7f72536",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.0",
"ext-mongodb": "^1.21.0",
"php": "^8.1",
"psr/log": "^1.1.4|^2|^3"
},
"replace": {
"mongodb/builder": "*"
},
"require-dev": {
"doctrine/coding-standard": "^12.0",
"phpunit/phpunit": "^10.5.35",
"rector/rector": "^2.1.4",
"squizlabs/php_codesniffer": "^3.7",
"vimeo/psalm": "6.5.*"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"MongoDB\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Andreas Braun",
"email": "andreas.braun@mongodb.com"
},
{
"name": "Jeremy Mikola",
"email": "jmikola@gmail.com"
},
{
"name": "Jérôme Tamarelle",
"email": "jerome.tamarelle@mongodb.com"
}
],
"description": "MongoDB driver library",
"homepage": "https://jira.mongodb.org/browse/PHPLIB",
"keywords": [
"database",
"driver",
"mongodb",
"persistence"
],
"support": {
"issues": "https://github.com/mongodb/mongo-php-library/issues",
"source": "https://github.com/mongodb/mongo-php-library/tree/1.21.3"
},
"time": "2025-09-22T12:34:29+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+00:00"
},
{ {
"name": "psr/simple-cache", "name": "psr/simple-cache",
"version": "3.0.0", "version": "3.0.0",
@ -182,6 +56,164 @@
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0" "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
}, },
"time": "2021-10-29T13:26:27+00:00" "time": "2021-10-29T13:26:27+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"provide": {
"ext-ctype": "*"
},
"suggest": {
"ext-ctype": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Ctype\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Gert de Pagter",
"email": "BackEndTea@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for ctype functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"ctype",
"polyfill",
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/yaml",
"version": "v8.0.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14",
"reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<7.4"
},
"require-dev": {
"symfony/console": "^7.4|^8.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v8.0.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-04T18:17:06+00:00"
} }
], ],
"packages-dev": [ "packages-dev": [
@ -744,16 +776,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "10.5.60", "version": "10.5.63",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c" "reference": "33198268dad71e926626b618f3ec3966661e4d90"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f2e26f52f80ef77832e359205f216eeac00e320c", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90",
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c", "reference": "33198268dad71e926626b618f3ec3966661e4d90",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -774,7 +806,7 @@
"phpunit/php-timer": "^6.0.0", "phpunit/php-timer": "^6.0.0",
"sebastian/cli-parser": "^2.0.1", "sebastian/cli-parser": "^2.0.1",
"sebastian/code-unit": "^2.0.0", "sebastian/code-unit": "^2.0.0",
"sebastian/comparator": "^5.0.4", "sebastian/comparator": "^5.0.5",
"sebastian/diff": "^5.1.1", "sebastian/diff": "^5.1.1",
"sebastian/environment": "^6.1.0", "sebastian/environment": "^6.1.0",
"sebastian/exporter": "^5.1.4", "sebastian/exporter": "^5.1.4",
@ -825,7 +857,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.60" "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63"
}, },
"funding": [ "funding": [
{ {
@ -849,7 +881,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-06T07:50:42+00:00" "time": "2026-01-27T05:48:37+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",
@ -1021,16 +1053,16 @@
}, },
{ {
"name": "sebastian/comparator", "name": "sebastian/comparator",
"version": "5.0.4", "version": "5.0.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git", "url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e" "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e", "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e", "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1086,7 +1118,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues", "issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy", "security": "https://github.com/sebastianbergmann/comparator/security/policy",
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4" "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
}, },
"funding": [ "funding": [
{ {
@ -1106,7 +1138,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-09-07T05:25:07+00:00" "time": "2026-01-24T09:25:16+00:00"
}, },
{ {
"name": "sebastian/complexity", "name": "sebastian/complexity",
@ -1862,7 +1894,7 @@
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2", "php": "^8.2",
"ext-mongodb": "^1.21" "ext-pdo": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"

View file

@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
use Pairity\Events\Events;
// SQLite demo DB
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Ensure table
$conn->execute('CREATE TABLE IF NOT EXISTS audit_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT,
name TEXT,
status TEXT
)');
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao {
public function getTable(): string { return 'audit_users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>[
'id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'name'=>['cast'=>'string'],'status'=>['cast'=>'string']
]]; }
}
// Simple audit buffer
$audit = [];
// Register listeners
Events::dispatcher()->clear();
Events::dispatcher()->listen('dao.beforeInsert', function(array &$p) {
if (($p['table'] ?? '') === 'audit_users') {
// normalize
$p['data']['email'] = strtolower((string)($p['data']['email'] ?? ''));
}
});
Events::dispatcher()->listen('dao.afterInsert', function(array &$p) use (&$audit) {
if (($p['table'] ?? '') === 'audit_users' && isset($p['dto'])) {
$audit[] = '[afterInsert] id=' . ($p['dto']->toArray(false)['id'] ?? '?');
}
});
Events::dispatcher()->listen('dao.afterUpdate', function(array &$p) use (&$audit) {
if (($p['table'] ?? '') === 'audit_users' && isset($p['dto'])) {
$audit[] = '[afterUpdate] id=' . ($p['dto']->toArray(false)['id'] ?? '?');
}
});
$dao = new UserDao($conn);
// Clean for demo
foreach ($dao->findAllBy() as $row) { $dao->deleteById((int)$row->toArray(false)['id']); }
// Perform some ops
$u = $dao->insert(['email' => 'AUDIT@EXAMPLE.COM', 'name' => 'Audit Me']);
$id = (int)($u->toArray(false)['id'] ?? 0);
$dao->update($id, ['name' => 'Audited']);
echo "Audit log:\n" . implode("\n", $audit) . "\n";

View file

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

View file

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

View file

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

View file

@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
// Configure MySQL connection (adjust credentials as needed)
$conn = ConnectionManager::make([
'driver' => 'mysql',
'host' => getenv('MYSQL_HOST') ?: '127.0.0.1',
'port' => (int)(getenv('MYSQL_PORT') ?: 3306),
'database' => getenv('MYSQL_DB') ?: 'app',
'username' => getenv('MYSQL_USER') ?: 'root',
'password' => getenv('MYSQL_PASS') ?: 'secret',
'charset' => 'utf8mb4',
]);
// Ensure demo tables (idempotent)
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL
)');
$conn->execute('CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(190) NOT NULL,
deleted_at DATETIME NULL
)');
class UserDto extends AbstractDto {}
class PostDto extends AbstractDto {}
class PostDao extends AbstractDao {
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return PostDto::class; }
protected function schema(): array { return [
'primaryKey' => 'id',
'columns' => [ 'id'=>['cast'=>'int'], 'user_id'=>['cast'=>'int'], 'title'=>['cast'=>'string'], 'deleted_at'=>['cast'=>'datetime'] ],
'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
]; }
}
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,
'foreignKey' => 'user_id',
'localKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
}
$userDao = new UserDao($conn);
$postDao = new PostDao($conn);
// Clean minimal (demo only)
foreach ($userDao->findAllBy() as $u) { $userDao->deleteById((int)$u->toArray(false)['id']); }
foreach ($postDao->findAllBy() as $p) { $postDao->deleteById((int)$p->toArray(false)['id']); }
// Seed
$u1 = $userDao->insert(['name' => 'Alice']);
$u2 = $userDao->insert(['name' => 'Bob']);
$uid1 = (int)$u1->toArray(false)['id'];
$uid2 = (int)$u2->toArray(false)['id'];
$postDao->insert(['user_id' => $uid1, 'title' => 'P1']);
$postDao->insert(['user_id' => $uid1, 'title' => 'P2']);
$postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]);
// Baseline portable eager (batched IN)
$batched = $userDao->fields('id','name','posts.title')->with(['posts'])->findAllBy([]);
echo "Batched eager: \n";
foreach ($batched as $u) {
$arr = $u->toArray(false);
$titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []);
echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n";
}
// Join-based eager (global opt-in)
$joined = $userDao->fields('id','name','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]);
echo "\nJoin eager (global): \n";
foreach ($joined as $u) {
$arr = $u->toArray(false);
$titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []);
echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n";
}
// Per-relation join hint (equivalent in this single-rel case)
$hinted = $userDao->fields('id','name','posts.title')
->with(['posts' => ['strategy' => 'join']])
->findAllBy([]); // will fallback to batched if conditions not met
echo "\nJoin eager (per-relation hint): \n";
foreach ($hinted as $u) {
$arr = $u->toArray(false);
$titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []);
echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n";
}

View file

@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
// Configure MySQL connection (adjust credentials as needed)
$conn = ConnectionManager::make([
'driver' => 'mysql',
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'app',
'username' => 'root',
'password' => 'secret',
'charset' => 'utf8mb4',
]);
// Ensure demo tables (idempotent)
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL
)');
$conn->execute('CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL
)');
$conn->execute('CREATE TABLE IF NOT EXISTS user_role (
user_id INT NOT NULL,
role_id INT NOT NULL
)');
class UserDto extends AbstractDto {}
class RoleDto extends AbstractDto {}
class RoleDao extends AbstractDao {
public function getTable(): string { return 'roles'; }
protected function dtoClass(): string { return RoleDto::class; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
}
class UserDao extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function relations(): array {
return [
'roles' => [
'type' => 'belongsToMany',
'dao' => RoleDao::class,
'pivot' => 'user_role',
'foreignPivotKey' => 'user_id',
'relatedPivotKey' => 'role_id',
'localKey' => 'id',
'relatedKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; }
}
$roleDao = new RoleDao($conn);
$userDao = new UserDao($conn);
// Clean minimal (demo only)
foreach ($userDao->findAllBy() as $u) { $userDao->deleteById((int)$u->toArray(false)['id']); }
foreach ($roleDao->findAllBy() as $r) { $roleDao->deleteById((int)$r->toArray(false)['id']); }
$conn->execute('DELETE FROM user_role');
// Seed
$admin = $roleDao->insert(['name' => 'admin']);
$editor = $roleDao->insert(['name' => 'editor']);
$u = $userDao->insert(['email' => 'pivot@example.com']);
$uid = (int)$u->toArray(false)['id'];
$ridAdmin = (int)$admin->toArray(false)['id'];
$ridEditor = (int)$editor->toArray(false)['id'];
// Attach roles
$userDao->attach('roles', $uid, [$ridAdmin, $ridEditor]);
$with = $userDao->with(['roles'])->findById($uid);
echo "User with roles: " . json_encode($with?->toArray(true)) . "\n";
// Detach one role
$userDao->detach('roles', $uid, [$ridAdmin]);
$with = $userDao->with(['roles'])->findById($uid);
echo "After detach: " . json_encode($with?->toArray(true)) . "\n";
// Sync to only [editor]
$userDao->sync('roles', $uid, [$ridEditor]);
$with = $userDao->with(['roles'])->findById($uid);
echo "After sync: " . json_encode($with?->toArray(true)) . "\n";

View file

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use Pairity\NoSql\Mongo\MongoConnectionManager;
// Configure via URI or discrete params
$conn = MongoConnectionManager::make([
// 'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin',
'host' => '127.0.0.1',
'port' => 27017,
]);
$db = 'pairity_demo';
$col = 'users';
// Clean collection for demo
foreach ($conn->find($db, $col, []) as $doc) {
$conn->deleteOne($db, $col, ['_id' => $doc['_id']]);
}
// Insert
$id = $conn->insertOne($db, $col, [
'email' => 'mongo@example.com',
'name' => 'Mongo User',
'status'=> 'active',
]);
echo "Inserted _id={$id}\n";
// Find
$found = $conn->find($db, $col, ['_id' => $id]);
echo 'Found: ' . json_encode($found, JSON_UNESCAPED_SLASHES) . PHP_EOL;
// Update
$conn->updateOne($db, $col, ['_id' => $id], ['$set' => ['name' => 'Updated Mongo User']]);
$after = $conn->find($db, $col, ['_id' => $id]);
echo 'After update: ' . json_encode($after, JSON_UNESCAPED_SLASHES) . PHP_EOL;
// Aggregate (simple match projection)
$agg = $conn->aggregate($db, $col, [
['$match' => ['status' => 'active']],
['$project' => ['email' => 1, 'name' => 1]],
]);
echo 'Aggregate: ' . json_encode($agg, JSON_UNESCAPED_SLASHES) . PHP_EOL;
// Delete
$deleted = $conn->deleteOne($db, $col, ['_id' => $id]);
echo "Deleted: {$deleted}\n";

View file

@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
// Connect via URI or discrete params
$conn = MongoConnectionManager::make([
// 'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin',
'host' => '127.0.0.1',
'port' => 27017,
]);
class UserDoc extends AbstractDto {}
class UserMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.users'; }
protected function dtoClass(): string { return UserDoc::class; }
}
$dao = new UserMongoDao($conn);
// Clean for demo
foreach ($dao->findAllBy([]) as $dto) {
$id = (string)($dto->toArray(false)['_id'] ?? '');
if ($id) { $dao->deleteById($id); }
}
// Insert
$created = $dao->insert([
'email' => 'mongo@example.com',
'name' => 'Mongo User',
'status'=> 'active',
]);
echo 'Inserted: ' . json_encode($created->toArray(false)) . "\n";
// Find by dynamic helper
$found = $dao->findOneByEmail('mongo@example.com');
echo 'Found: ' . json_encode($found?->toArray(false)) . "\n";
// Update
if ($found) {
$id = (string)$found->toArray(false)['_id'];
$updated = $dao->update($id, ['name' => 'Updated Mongo User']);
echo 'Updated: ' . json_encode($updated->toArray(false)) . "\n";
}
// Projection + sort + limit
$list = $dao->fields('email', 'name')->sort(['email' => 1])->limit(10)->findAllBy(['status' => 'active']);
echo 'List (projected): ' . json_encode(array_map(fn($d) => $d->toArray(false), $list)) . "\n";
// Delete
if ($found) {
$id = (string)$found->toArray(false)['_id'];
$deleted = $dao->deleteById($id);
echo "Deleted: {$deleted}\n";
}

View file

@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
// Connect via URI or discrete params
$conn = MongoConnectionManager::make([
// 'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin',
'host' => '127.0.0.1',
'port' => 27017,
]);
class UserDoc extends AbstractDto {}
class PostDoc extends AbstractDto {}
class PostMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.pg_posts'; }
protected function dtoClass(): string { return PostDoc::class; }
}
class UserMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.pg_users'; }
protected function dtoClass(): string { return UserDoc::class; }
protected function relations(): array
{
return [
'posts' => [
'type' => 'hasMany',
'dao' => PostMongoDao::class,
'foreignKey' => 'user_id',
'localKey' => '_id',
],
];
}
}
$userDao = new UserMongoDao($conn);
$postDao = new PostMongoDao($conn);
// Clean collections for demo
foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } }
foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } }
// Seed 22 users; every 3rd has a post
for ($i=1; $i<=22; $i++) {
$status = $i % 2 === 0 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "mp{$i}@example.com", 'status' => $status]);
$uid = (string)($u->toArray(false)['_id'] ?? '');
if ($i % 3 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'Post '.$i]); }
}
// Paginate
$page1 = $userDao->paginate(1, 10, []);
echo "Page1 total={$page1['total']} lastPage={$page1['lastPage']} count=".count($page1['data'])."\n";
// Simple paginate
$sp = $userDao->simplePaginate(3, 10, []);
echo 'Simple nextPage on page 3: ' . json_encode($sp['nextPage']) . "\n";
// Projection + sort + eager relation
$with = $userDao->fields('email','posts.title')->sort(['email' => 1])->with(['posts'])->paginate(1, 5, []);
echo 'With posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $with['data'])) . "\n";
// Named scope example
$userDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; });
$active = $userDao->active()->paginate(1, 100, []);
echo "Active total: {$active['total']}\n";

View file

@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
class UserDoc extends AbstractDto {}
class PostDoc extends AbstractDto {}
class UserMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.users'; }
protected function dtoClass(): string { return UserDoc::class; }
protected function relations(): array
{
return [
'posts' => [
'type' => 'hasMany',
'dao' => PostMongoDao::class,
'foreignKey' => 'user_id',
'localKey' => '_id',
],
];
}
}
class PostMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.posts'; }
protected function dtoClass(): string { return PostDoc::class; }
protected function relations(): array
{
return [
'user' => [
'type' => 'belongsTo',
'dao' => UserMongoDao::class,
'foreignKey' => 'user_id',
'otherKey' => '_id',
],
];
}
}
$conn = MongoConnectionManager::make([
'host' => '127.0.0.1',
'port' => 27017,
]);
$userDao = new UserMongoDao($conn);
$postDao = new PostMongoDao($conn);
// Clean
foreach ($postDao->findAllBy([]) as $p) { $postDao->deleteById((string)$p->toArray(false)['_id']); }
foreach ($userDao->findAllBy([]) as $u) { $userDao->deleteById((string)$u->toArray(false)['_id']); }
// Seed
$u = $userDao->insert(['email' => 'mongo@example.com', 'name' => 'Alice']);
$uid = (string)$u->toArray(false)['_id'];
$p1 = $postDao->insert(['title' => 'First', 'user_id' => $uid]);
$p2 = $postDao->insert(['title' => 'Second', 'user_id' => $uid]);
// Eager load posts on users
$users = $userDao->fields('email', 'name', 'posts.title')->with(['posts'])->findAllBy([]);
echo 'Users with posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $users)) . "\n";
// Lazy load user on a post
$onePost = $postDao->findOneBy(['title' => 'First']);
if ($onePost) {
$postDao->load($onePost, 'user');
echo 'Post with user: ' . json_encode($onePost->toArray()) . "\n";
}

View file

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

View file

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

View file

@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
// SQLite connection (file db.sqlite in project root)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Demo tables
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT,
status TEXT
)');
$conn->execute('CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
title TEXT
)');
class UserDto extends AbstractDto {}
class PostDto extends AbstractDto {}
class PostDao extends AbstractDao {
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return PostDto::class; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
}
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,
'foreignKey' => 'user_id',
'localKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'status'=>['cast'=>'string']]]; }
}
$userDao = new UserDao($conn);
$postDao = new PostDao($conn);
// Seed a few users if table is empty
$hasAny = $userDao->findAllBy();
if (!$hasAny) {
for ($i=1; $i<=25; $i++) {
$status = $i % 2 === 0 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "p{$i}@example.com", 'status' => $status]);
$uid = (int)($u->toArray(false)['id'] ?? 0);
if ($i % 5 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'Hello '.$i]); }
}
}
// Paginate (page 1, perPage 10)
$page1 = $userDao->paginate(1, 10);
echo "Page 1: total={$page1['total']} lastPage={$page1['lastPage']} count=".count($page1['data'])."\n";
// Simple paginate (detect next page)
$sp = $userDao->simplePaginate(1, 10);
echo 'Simple nextPage: ' . json_encode($sp['nextPage']) . "\n";
// Projection + eager load
$with = $userDao->fields('id','email','posts.title')->with(['posts'])->paginate(1, 5);
echo 'With posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $with['data'])) . "\n";
// Named scope
$userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; });
$active = $userDao->active()->paginate(1, 50);
echo "Active total: {$active['total']}\n";

View file

@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
use Pairity\Orm\UnitOfWork;
// SQLite demo DB (local file)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Ensure table
$conn->execute('CREATE TABLE IF NOT EXISTS uow_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
version INTEGER NOT NULL DEFAULT 0
)');
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao {
public function getTable(): string { return 'uow_users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'name' => ['cast' => 'string'],
'version' => ['cast' => 'int'],
],
// Enable optimistic locking on integer version column
'locking' => ['type' => 'version', 'column' => 'version'],
];
}
}
$dao = new UserDao($conn);
// Clean for demo
foreach ($dao->findAllBy() as $row) {
$dao->deleteById((int)($row->toArray(false)['id'] ?? 0));
}
// Create one
$u = $dao->insert(['name' => 'Alice']);
$id = (int)($u->toArray(false)['id'] ?? 0);
// Demonstrate UoW with snapshot diffing: modify DTO then commit
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
// Enable snapshot diffing (optional)
$uow->enableSnapshots(true);
// Load and modify the DTO directly (no explicit update call)
$user = $dao->findById($id);
if ($user) {
// mutate DTO attributes
$user->setRelation('name', 'Alice (uow)');
}
// Also stage an explicit update to show coalescing
$dao->update($id, ['name' => 'Alice (explicit)']);
});
$after = $dao->findById($id);
echo 'After UoW commit: ' . json_encode($after?->toArray(false)) . "\n";

161
pairity-schema.json Normal file
View file

@ -0,0 +1,161 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Pairity Table Definition",
"type": "object",
"properties": {
"prefix": {
"type": "string"
},
"tenancy": {
"type": "boolean"
},
"auditable": {
"type": "boolean"
},
"timestamps": {
"type": "boolean"
},
"softDeletes": {
"type": "boolean"
},
"inheritance": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"sti",
"cti"
]
},
"discriminator": {
"type": "string"
},
"parent": {
"type": "string"
}
}
},
"morph": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"id": {
"type": "string"
}
}
},
"columns": {
"type": "object",
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string"
},
"length": {
"type": "integer"
},
"precision": {
"type": "integer"
},
"scale": {
"type": "integer"
},
"nullable": {
"type": "boolean"
},
"unique": {
"type": "boolean"
},
"primary": {
"type": "boolean"
},
"index": {
"type": "boolean"
},
"encrypted": {
"type": "boolean"
},
"unsigned": {
"type": "boolean"
},
"autoIncrement": {
"type": "boolean"
},
"default": {
"type": [
"string",
"number",
"boolean",
"null"
]
},
"comment": {
"type": "string"
}
}
}
]
}
},
"indexes": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"relations": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": [
"type",
"model"
],
"properties": {
"type": {
"type": "string",
"enum": [
"belongsTo",
"hasOne",
"hasMany",
"belongsToMany",
"morphTo",
"morphOne",
"morphMany"
]
},
"model": {
"type": "string"
},
"foreignKey": {
"type": "string"
},
"localKey": {
"type": "string"
},
"pivotTable": {
"type": "string"
}
}
}
}
},
"required": [
"columns"
]
}

196
src/Cache/FileCache.php Normal file
View file

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Pairity\Cache;
use Psr\SimpleCache\CacheInterface;
use RuntimeException;
/**
* Class FileCache
*
* A simple PSR-16 compliant file-based cache implementation.
*
* @package Pairity\Cache
*/
class FileCache implements CacheInterface
{
/**
* FileCache constructor.
*
* @param string $directory The directory to store cache files.
*/
public function __construct(
protected string $directory
) {
$this->ensureDirectoryExists();
}
/**
* @inheritDoc
*/
public function get(string $key, mixed $default = null): mixed
{
$file = $this->getFilePath($key);
if (!file_exists($file)) {
return $default;
}
$content = file_get_contents($file);
if ($content === false) {
return $default;
}
try {
$data = unserialize($content);
} catch (\Throwable) {
return $default;
}
if ($data['expires_at'] !== null && $data['expires_at'] < time()) {
$this->delete($key);
return $default;
}
return $data['value'];
}
/**
* @inheritDoc
*/
public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool
{
$expiresAt = $this->calculateExpiry($ttl);
$file = $this->getFilePath($key);
$data = [
'value' => $value,
'expires_at' => $expiresAt,
];
return file_put_contents($file, serialize($data)) !== false;
}
/**
* @inheritDoc
*/
public function delete(string $key): bool
{
$file = $this->getFilePath($key);
if (file_exists($file)) {
return unlink($file);
}
return true;
}
/**
* @inheritDoc
*/
public function clear(): bool
{
$files = glob($this->directory . '/*.cache');
foreach ($files as $file) {
unlink($file);
}
return true;
}
/**
* @inheritDoc
*/
public function getMultiple(iterable $keys, mixed $default = null): iterable
{
$results = [];
foreach ($keys as $key) {
$results[$key] = $this->get($key, $default);
}
return $results;
}
/**
* @inheritDoc
*/
public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool
{
foreach ($values as $key => $value) {
if (!$this->set($key, $value, $ttl)) {
return false;
}
}
return true;
}
/**
* @inheritDoc
*/
public function deleteMultiple(iterable $keys): bool
{
foreach ($keys as $key) {
$this->delete($key);
}
return true;
}
/**
* @inheritDoc
*/
public function has(string $key): bool
{
return $this->get($key, $this) !== $this;
}
/**
* Get the file path for a cache key.
*
* @param string $key
* @return string
*/
protected function getFilePath(string $key): string
{
return rtrim($this->directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . md5($key) . '.cache';
}
/**
* Calculate the expiry timestamp.
*
* @param \DateInterval|int|null $ttl
* @return int|null
*/
protected function calculateExpiry(\DateInterval|int|null $ttl): ?int
{
if ($ttl === null) {
return null;
}
if ($ttl instanceof \DateInterval) {
$now = new \DateTime();
$now->add($ttl);
return $now->getTimestamp();
}
return time() + $ttl;
}
/**
* Ensure the cache directory exists and is writable.
*
* @return void
* @throws RuntimeException
*/
protected function ensureDirectoryExists(): void
{
if (!is_dir($this->directory)) {
if (!mkdir($this->directory, 0755, true) && !is_dir($this->directory)) {
throw new RuntimeException("Directory [{$this->directory}] was not created.");
}
}
if (!is_writable($this->directory)) {
throw new RuntimeException("Directory [{$this->directory}] is not writable.");
}
}
}

View file

@ -1,102 +0,0 @@
<?php
namespace Pairity\Console;
use Pairity\Database\ConnectionManager;
use Pairity\Migrations\Migrator;
use Pairity\Migrations\MigrationLoader;
abstract class AbstractCommand implements CommandInterface
{
protected function stdout(string $msg): void
{
echo $msg . PHP_EOL;
}
protected function stderr(string $msg): void
{
fwrite(STDERR, $msg . PHP_EOL);
}
protected function getConnection(array $args): \Pairity\Contracts\ConnectionInterface
{
$config = $this->loadConfig($args);
return ConnectionManager::make($config);
}
protected function loadConfig(array $args): array
{
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 'sqlite':
$cfg += [
'path' => getenv('DB_PATH') ?: 'db.sqlite',
];
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;
}
return $cfg;
}
// Fallback to SQLite in project root
return [
'driver' => 'sqlite',
'path' => 'db.sqlite'
];
}
protected function getMigrationsDir(array $args): string
{
$dir = $args['path'] ?? 'migrations';
if (!str_starts_with($dir, '/') && !str_starts_with($dir, './')) {
$dir = getcwd() . DIRECTORY_SEPARATOR . $dir;
}
return $dir;
}
}

167
src/Console/Application.php Normal file
View file

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Pairity\Console;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Container\ContainerInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
use RuntimeException;
/**
* Class Application
*
* The main console application for Pairity.
* Responsible for command registration, parsing arguments, and dispatching execution.
*
* @package Pairity\Console
*/
class Application
{
/**
* @var array<string, CommandInterface>
*/
protected array $commands = [];
/**
* Application constructor.
*
* @param ContainerInterface $container The service container instance.
* @param string $name The application name.
* @param string $version The application version.
*/
public function __construct(
protected ContainerInterface $container,
protected string $name = 'Pairity CLI',
protected string $version = '1.0.0'
) {
}
/**
* Register a command with the application.
*
* @param string|CommandInterface $command The command class name or instance.
* @return void
*/
public function add(string|CommandInterface $command): void
{
if (is_string($command)) {
$command = $this->container->make($command);
}
if (!$command instanceof CommandInterface) {
throw new RuntimeException('Command must implement Pairity\Contracts\Console\CommandInterface');
}
$this->commands[$command->getName()] = $command;
}
/**
* Run the application.
*
* @param array $argv The raw command line arguments (usually from $_SERVER['argv']).
* @return int The exit code.
*/
public function run(array $argv): int
{
// Remove the script name from argv
array_shift($argv);
if (empty($argv)) {
$this->printHelp();
return 0;
}
$commandName = array_shift($argv);
if ($commandName === '--version' || $commandName === '-V') {
$this->printVersion();
return 0;
}
if (!isset($this->commands[$commandName])) {
$message = $this->t('error.command_not_found', ['command' => $commandName]);
fwrite(STDERR, $message . "\n");
return 1;
}
$command = $this->commands[$commandName];
// Basic argument/option parsing (can be expanded later)
$args = [];
$options = [];
foreach ($argv as $arg) {
if (str_starts_with($arg, '-')) {
$options[] = $arg;
} else {
$args[] = $arg;
}
}
try {
return $command->execute($args, $options);
} catch (\Throwable $e) {
$message = $this->t('error.uncaught_exception', ['message' => $e->getMessage()]);
fwrite(STDERR, $message . "\n");
return 1;
}
}
/**
* Print the application version information.
*
* @return void
*/
protected function printVersion(): void
{
$line = $this->t('app.version', ['name' => $this->name, 'version' => $this->version]);
echo $line . "\n";
}
/**
* Print the help information including available commands.
*
* @return void
*/
protected function printHelp(): void
{
$this->printVersion();
echo $this->t('app.usage');
echo $this->t('app.available_commands') . "\n";
ksort($this->commands);
foreach ($this->commands as $name => $command) {
printf(" %-20s %s\n", $name, $command->getDescription());
}
}
/**
* Translate a message if a Translator is available.
* Falls back to the key itself or plain text when not available.
*
* @param string $key
* @param array<string, mixed> $replace
* @return string
*/
protected function t(string $key, array $replace = []): string
{
if ($this->container->has(TranslatorInterface::class)) {
/** @var TranslatorInterface $translator */
$translator = $this->container->get(TranslatorInterface::class);
return $translator->trans($key, $replace);
}
// Fallbacks for a few known keys to preserve previous behavior
return match ($key) {
'app.usage' => "\nUsage:\n pairity [command] [arguments] [options]\n\n",
'app.available_commands' => 'Available commands:',
'app.version' => ($replace['name'] ?? 'Pairity CLI') . ' version ' . ($replace['version'] ?? '1.0.0'),
'error.command_not_found' => "Command '" . ($replace['command'] ?? '') . "' not found.",
'error.uncaught_exception' => 'Uncaught Exception: ' . ($replace['message'] ?? ''),
default => $key,
};
}
}

View file

@ -1,13 +0,0 @@
<?php
namespace Pairity\Console;
interface CommandInterface
{
/**
* Execute the command.
*
* @param array<string, mixed> $args
*/
public function execute(array $args): void;
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
use Pairity\Schema\MetadataManager;
/**
* Class CacheClearCommand
*
* CLI command to clear the metadata cache.
*
* @package Pairity\Console\Commands
*/
class CacheClearCommand implements CommandInterface
{
/**
* CacheClearCommand constructor.
*
* @param MetadataManager $metadataManager
* @param TranslatorInterface|null $translator
*/
public function __construct(
protected MetadataManager $metadataManager,
protected ?TranslatorInterface $translator = null
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'cache:clear';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->t('command.cache_clear.description', 'Clear the Pairity metadata cache.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
echo $this->t('command.cache_clear.starting', 'Clearing metadata cache...') . "\n";
if ($this->metadataManager->clearCache()) {
echo $this->t('command.cache_clear.success', 'Metadata cache cleared successfully.') . "\n";
return 0;
}
echo $this->t('command.cache_clear.error', 'Failed to clear metadata cache.') . "\n";
return 1;
}
/**
* Translate a message if a Translator is available.
*
* @param string $key
* @param string $default
* @param array $replace
* @return string
*/
protected function t(string $key, string $default, array $replace = []): string
{
if ($this->translator) {
return $this->translator->trans($key, $replace);
}
foreach ($replace as $placeholder => $value) {
$default = str_replace('{' . $placeholder . '}', (string) $value, $default);
}
return $default;
}
}

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
/**
* Class DatabaseHealthCheckCommand
*
* CLI command to verify database connection health.
*
* @package Pairity\Console\Commands
*/
class DatabaseHealthCheckCommand implements CommandInterface
{
/**
* DatabaseHealthCheckCommand constructor.
*
* @param DatabaseManagerInterface $db
* @param TranslatorInterface|null $translator
*/
public function __construct(
protected DatabaseManagerInterface $db,
protected ?TranslatorInterface $translator = null
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'db:check:health';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->t('command.db_health_check.description', 'Verify database connection health and heartbeat.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$connectionName = $args[0] ?? null;
try {
$connection = $this->db->connection($connectionName);
$name = $connection->getName();
echo $this->t('command.db_health_check.checking', 'Checking health for connection: {name}...', ['name' => $name]) . "\n";
if ($connection->checkHealth()) {
echo $this->t('command.db_health_check.success', 'Connection {name} is healthy.', ['name' => $name]) . "\n";
return 0;
}
echo $this->t('command.db_health_check.failed', 'Connection {name} is unhealthy.', ['name' => $name]) . "\n";
return 1;
} catch (\Throwable $e) {
echo $this->t('command.db_health_check.error', 'Error checking health: {message}', ['message' => $e->getMessage()]) . "\n";
return 1;
}
}
/**
* Translate a message if a Translator is available.
*
* @param string $key
* @param string $default
* @param array $replace
* @return string
*/
protected function t(string $key, string $default, array $replace = []): string
{
if ($this->translator) {
return $this->translator->trans($key, $replace);
}
foreach ($replace as $placeholder => $value) {
$default = str_replace('{' . $placeholder . '}', (string) $value, $default);
}
return $default;
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
use Pairity\Schema\JsonSchemaGenerator;
/**
* Class GenerateJsonSchemaCommand
*
* CLI command to generate the JSON Schema for Pairity YAML table definitions.
*
* @package Pairity\Console\Commands
*/
class GenerateJsonSchemaCommand implements CommandInterface
{
/**
* GenerateJsonSchemaCommand constructor.
*
* @param JsonSchemaGenerator $generator
* @param TranslatorInterface|null $translator
*/
public function __construct(
protected JsonSchemaGenerator $generator,
protected ?TranslatorInterface $translator = null
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'schema:json';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->t('command.schema_json.description', 'Generate the JSON Schema for Pairity YAML table definitions.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$outputFile = $args[0] ?? 'pairity-schema.json';
try {
$json = $this->generator->generateJson();
file_put_contents($outputFile, $json);
echo $this->t('command.schema_json.success', 'JSON Schema generated successfully at: {path}', ['path' => $outputFile]) . "\n";
return 0;
} catch (\Throwable $e) {
echo $this->t('command.schema_json.error', 'Error generating JSON Schema: {message}', ['message' => $e->getMessage()]) . "\n";
return 1;
}
}
/**
* Translate a message if a Translator is available.
*
* @param string $key
* @param string $default
* @param array $replace
* @return string
*/
protected function t(string $key, string $default, array $replace = []): string
{
if ($this->translator) {
return $this->translator->trans($key, $replace);
}
foreach ($replace as $placeholder => $value) {
$default = str_replace('{' . $placeholder . '}', (string) $value, $default);
}
return $default;
}
}

View file

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
use Pairity\Schema\YamlSchemaParser;
use Pairity\Schema\CodeGenerator;
use RuntimeException;
/**
* Class GenerateModelsCommand
*
* CLI command to generate DTO and DAO classes from YAML schema definitions.
*
* @package Pairity\Console\Commands
*/
class GenerateModelsCommand implements CommandInterface
{
/**
* GenerateModelsCommand constructor.
*
* @param YamlSchemaParser $parser
* @param CodeGenerator $generator
* @param TranslatorInterface|null $translator
*/
public function __construct(
protected YamlSchemaParser $parser,
protected CodeGenerator $generator,
protected ?TranslatorInterface $translator = null
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'make:model';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->t('command.make_model.description', 'Generate DTO and DAO classes from YAML schema definitions.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$schemaPath = $args[0] ?? 'schema';
$outputPath = $args[1] ?? 'src/Models'; // Default output path
$namespace = 'App\\Models'; // Default namespace
if (!is_dir($schemaPath)) {
echo $this->t('command.make_model.no_directory', 'Schema directory not found: {path}', ['path' => $schemaPath]) . "\n";
return 1;
}
$files = glob($schemaPath . '/*.yaml');
if (empty($files)) {
echo $this->t('command.make_model.no_files', 'No YAML schema files found in: {path}', ['path' => $schemaPath]) . "\n";
return 0;
}
if (!is_dir($outputPath)) {
mkdir($outputPath, 0755, true);
}
echo $this->t('command.make_model.starting', 'Generating DTO and DAO classes...') . "\n";
foreach ($files as $file) {
try {
$blueprint = $this->parser->parseFile($file);
// Generate DTO
$dtoCode = $this->generator->generateDto($blueprint, $namespace . '\\DTO');
$dtoPath = $outputPath . DIRECTORY_SEPARATOR . 'DTO';
if (!is_dir($dtoPath)) mkdir($dtoPath, 0755, true);
$dtoFileName = $dtoPath . DIRECTORY_SEPARATOR . $this->studly($blueprint->getTableName()) . 'DTO.php';
file_put_contents($dtoFileName, $dtoCode);
// Generate DAO
$daoCode = $this->generator->generateDao($blueprint, $namespace . '\\DAO', $namespace . '\\DTO');
$daoPath = $outputPath . DIRECTORY_SEPARATOR . 'DAO';
if (!is_dir($daoPath)) mkdir($daoPath, 0755, true);
$daoFileName = $daoPath . DIRECTORY_SEPARATOR . $this->studly($blueprint->getTableName()) . 'DAO.php';
file_put_contents($daoFileName, $daoCode);
// Generate Hydrator
$hydratorCode = $this->generator->generateHydrator($blueprint, $namespace . '\\Hydrators', $namespace . '\\DTO\\' . $this->studly($blueprint->getTableName()) . 'DTO');
$hydratorPath = $outputPath . DIRECTORY_SEPARATOR . 'Hydrators';
if (!is_dir($hydratorPath)) mkdir($hydratorPath, 0755, true);
$hydratorFileName = $hydratorPath . DIRECTORY_SEPARATOR . $this->studly($blueprint->getTableName()) . 'Hydrator.php';
file_put_contents($hydratorFileName, $hydratorCode);
echo " - {$blueprint->getTableName()} -> " . $this->studly($blueprint->getTableName()) . " DTO/DAO/Hydrator\n";
} catch (\Throwable $e) {
echo " - Error processing {$file}: {$e->getMessage()}\n";
return 1;
}
}
echo "\n" . $this->t('command.make_model.finished', 'Code generation completed successfully.') . "\n";
return 0;
}
/**
* Convert string to StudlyCase.
*
* @param string $value
* @return string
*/
protected function studly(string $value): string
{
$value = ucwords(str_replace(['-', '_'], ' ', $value));
return str_replace(' ', '', $value);
}
/**
* Translate a message if a Translator is available.
*
* @param string $key
* @param string $default
* @param array $replace
* @return string
*/
protected function t(string $key, string $default, array $replace = []): string
{
if ($this->translator) {
return $this->translator->trans($key, $replace);
}
foreach ($replace as $placeholder => $value) {
$default = str_replace('{' . $placeholder . '}', (string) $value, $default);
}
return $default;
}
}

View file

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
use Pairity\Schema\YamlSchemaParser;
use Pairity\Schema\BlueprintSerializer;
use RuntimeException;
/**
* Class GenerateSchemaSnapshotCommand
*
* CLI command to generate a schema snapshot from YAML definitions.
*
* @package Pairity\Console\Commands
*/
class GenerateSchemaSnapshotCommand implements CommandInterface
{
/**
* GenerateSchemaSnapshotCommand constructor.
*
* @param YamlSchemaParser $parser
* @param BlueprintSerializer $serializer
* @param TranslatorInterface|null $translator
*/
public function __construct(
protected YamlSchemaParser $parser,
protected BlueprintSerializer $serializer,
protected ?TranslatorInterface $translator = null
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'make:yaml:snapshot';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->t('command.schema_snapshot.description', 'Export the current YAML source of truth into a PHP baseline snapshot.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$schemaPath = $args[0] ?? 'schema';
$snapshotPath = $args[1] ?? 'schema/snapshots';
if (!is_dir($schemaPath)) {
echo $this->t('command.schema_snapshot.no_directory', 'Schema directory not found: {path}', ['path' => $schemaPath]) . "\n";
return 1;
}
if (!is_dir($snapshotPath)) {
mkdir($snapshotPath, 0755, true);
}
$files = glob($schemaPath . '/*.yaml');
if (empty($files)) {
echo $this->t('command.schema_snapshot.no_files', 'No YAML schema files found in: {path}', ['path' => $schemaPath]) . "\n";
return 0;
}
echo $this->t('command.schema_snapshot.starting', 'Generating schema snapshots...') . "\n";
foreach ($files as $file) {
$tableName = pathinfo($file, PATHINFO_FILENAME);
try {
$blueprint = $this->parser->parseFile($file);
$phpCode = $this->serializer->toPhpCode($blueprint);
$outputFile = $snapshotPath . DIRECTORY_SEPARATOR . $tableName . '.php';
file_put_contents($outputFile, $phpCode);
echo " - {$tableName} -> {$outputFile}\n";
} catch (\Throwable $e) {
echo $this->t('command.schema_snapshot.error', 'Error processing {file}: {message}', ['file' => $file, 'message' => $e->getMessage()]) . "\n";
return 1;
}
}
echo "\n" . $this->t('command.schema_snapshot.finished', 'Snapshot generation completed successfully.') . "\n";
return 0;
}
/**
* Translate a message if a Translator is available.
*
* @param string $key
* @param string $default
* @param array $replace
* @return string
*/
protected function t(string $key, string $default, array $replace = []): string
{
if ($this->translator) {
return $this->translator->trans($key, $replace);
}
foreach ($replace as $placeholder => $value) {
$default = str_replace('{' . $placeholder . '}', (string) $value, $default);
}
return $default;
}
}

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
/**
* Class InitCommand
*
* CLI command to initialize the Pairity ORM project structure.
*
* @package Pairity\Console\Commands
*/
class InitCommand implements CommandInterface
{
/**
* InitCommand constructor.
*
* @param TranslatorInterface|null $translator
*/
public function __construct(
protected ?TranslatorInterface $translator = null
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'init';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->t('command.init.description', 'Initialize the Pairity project structure.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$directories = [
'schema',
'storage/cache',
'src/Models/DTO',
'src/Models/DAO',
'src/Models/Hydrators',
'src/Database/Migrations',
'src/Database/Seeds',
'src/Database/Factories',
];
foreach ($directories as $dir) {
if (!is_dir($dir)) {
if (mkdir($dir, 0755, true)) {
echo "Created directory: {$dir}\n";
} else {
echo "Failed to create directory: {$dir}\n";
return 1;
}
} else {
echo "Directory already exists: {$dir}\n";
}
}
echo "Pairity initialization completed successfully.\n";
return 0;
}
/**
* Translate a message if a Translator is available.
*
* @param string $key
* @param string $default
* @param array $replace
* @return string
*/
protected function t(string $key, string $default, array $replace = []): string
{
if ($this->translator) {
return $this->translator->trans($key, $replace);
}
foreach ($replace as $placeholder => $value) {
$default = str_replace('{' . $placeholder . '}', (string) $value, $default);
}
return $default;
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
use Pairity\Database\Schema\Introspector;
use Symfony\Component\Yaml\Yaml;
/**
* Class IntrospectCommand
*
* CLI command to reverse-engineer an existing database to generate YAML schema files.
*/
class IntrospectCommand implements CommandInterface
{
/**
* IntrospectCommand constructor.
*
* @param DatabaseManagerInterface $db
* @param TranslatorInterface $translator
*/
public function __construct(
protected DatabaseManagerInterface $db,
protected TranslatorInterface $translator
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'make:yaml:fromdb';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->translator->trans('command.make_yaml_fromdb.description', 'Reverse-engineer an existing database to generate YAML schema files.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$connectionName = $options['connection'] ?? $this->db->getDefaultConnection();
$connection = $this->db->connection($connectionName);
$introspector = new Introspector($connection);
$tables = $introspector->getTables();
if (empty($tables)) {
echo "No tables found in database [{$connectionName}].\n";
return 0;
}
$outputDir = 'schema';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
echo "Introspecting database [{$connectionName}]...\n";
foreach ($tables as $table) {
echo " - {$table}\n";
$schema = $introspector->introspectTable($table);
$yaml = Yaml::dump($schema, 4);
file_put_contents($outputDir . '/' . $table . '.yaml', $yaml);
}
echo "Schema files generated successfully in [{$outputDir}].\n";
return 0;
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
/**
* Class MakeFactoryCommand
*
* CLI command to create a new factory class.
*/
class MakeFactoryCommand implements CommandInterface
{
/**
* MakeFactoryCommand constructor.
*
* @param DatabaseManagerInterface $db
* @param TranslatorInterface $translator
*/
public function __construct(
protected DatabaseManagerInterface $db,
protected TranslatorInterface $translator
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'make:factory';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->translator->trans('command.make_factory.description', 'Create a new factory class for a model.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$name = $args[0] ?? null;
if (!$name) {
echo "Error: Factory name is required (e.g., UserFactory).\n";
return 1;
}
$path = 'src/Database/Factories/' . $name . '.php';
if (file_exists($path)) {
echo "Error: Factory [{$name}] already exists.\n";
return 1;
}
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
$modelName = str_replace('Factory', '', $name);
$dtoFqcn = 'App\\Models\\DTO\\' . $modelName . 'DTO';
$daoFqcn = 'App\\Models\\DAO\\' . $modelName . 'DAO';
$content = "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Database\\Factories;\n\nuse Pairity\\Database\\Factories\\Factory;\nuse {$dtoFqcn};\nuse {$daoFqcn};\n\nclass {$name} extends Factory\n{\n /**\n * Define the model's default state.\n *\n * @return array<string, mixed>\n */\n public function definition(): array\n {\n return [\n // 'name' => 'John Doe',\n ];\n }\n\n /**\n * @inheritDoc\n */\n public function model(): string\n {\n return {$modelName}DTO::class;\n }\n\n /**\n * @inheritDoc\n */\n public function dao(): string\n {\n return {$modelName}DAO::class;\n }\n}\n";
file_put_contents($path, $content);
echo "Factory created successfully at [{$path}].\n";
return 0;
}
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
/**
* Class MakeMigrationCommand
*
* CLI command to create a new manual migration file.
*/
class MakeMigrationCommand implements CommandInterface
{
/**
* MakeMigrationCommand constructor.
*
* @param DatabaseManagerInterface $db
* @param TranslatorInterface $translator
*/
public function __construct(
protected DatabaseManagerInterface $db,
protected TranslatorInterface $translator
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'make:migration';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->translator->trans('command.make_migration.description', 'Create a new manual migration file for custom SQL or data changes.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$name = $args[0] ?? null;
if (!$name) {
echo "Error: Migration name is required.\n";
return 1;
}
$timestamp = date('Y_m_d_His');
$filename = $timestamp . '_' . $name . '.sql';
$path = 'src/Database/Migrations/' . $filename;
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
$content = "-- Pairity Migration: {$name}\n-- Created at: " . date('Y-m-d H:i:s') . "\n\n";
file_put_contents($path, $content);
echo "Migration created successfully at [{$path}].\n";
return 0;
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
/**
* Class MakeSeederCommand
*
* CLI command to create a new seeder class.
*/
class MakeSeederCommand implements CommandInterface
{
/**
* MakeSeederCommand constructor.
*
* @param DatabaseManagerInterface $db
* @param TranslatorInterface $translator
*/
public function __construct(
protected DatabaseManagerInterface $db,
protected TranslatorInterface $translator
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'make:seeder';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->translator->trans('command.make_seeder.description', 'Create a new seeder class.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$name = $args[0] ?? null;
if (!$name) {
echo "Error: Seeder name is required.\n";
return 1;
}
$path = 'src/Database/Seeds/' . $name . '.php';
if (file_exists($path)) {
echo "Error: Seeder [{$name}] already exists.\n";
return 1;
}
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
$content = "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Database\\Seeds;\n\nuse Pairity\\Database\\Seeding\\Seeder;\n\nclass {$name} extends Seeder\n{\n /**\n * Run the database seeds.\n *\n * @return void\n */\n public function run(): void\n {\n // \$this->call(SomeOtherSeeder::class);\n }\n}\n";
file_put_contents($path, $content);
echo "Seeder created successfully at [{$path}].\n";
return 0;
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
/**
* Class RunDataMigrationCommand
*
* CLI command to execute procedural PHP data migrations.
*/
class RunDataMigrationCommand implements CommandInterface
{
/**
* RunDataMigrationCommand constructor.
*
* @param DatabaseManagerInterface $db
* @param TranslatorInterface $translator
*/
public function __construct(
protected DatabaseManagerInterface $db,
protected TranslatorInterface $translator
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'migration:data';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->translator->trans('command.migration_data.description', 'Execute procedural PHP data migrations.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$class = $args[0] ?? null;
if (!$class) {
echo "Error: Data migration class is required.\n";
return 1;
}
if (!str_contains($class, '\\')) {
$class = 'App\\Database\\Migrations\\' . $class;
}
if (!class_exists($class)) {
echo "Error: Data migration class [{$class}] not found.\n";
return 1;
}
echo "Executing Data Migration: {$class}...\n";
/** @var \Pairity\Database\Migrations\DataMigration $migration */
$migration = new $class($this->db);
$rollback = in_array('--rollback', $options);
if ($rollback) {
$migration->down();
echo "Data Migration rolled back successfully.\n";
} else {
$migration->up();
echo "Data Migration executed successfully.\n";
}
return 0;
}
}

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
use Pairity\Schema\YamlSchemaParser;
use Pairity\Exceptions\SchemaException;
/**
* Class SchemaLintCommand
*
* CLI command to lint Pairity YAML table definitions.
*
* @package Pairity\Console\Commands
*/
class SchemaLintCommand implements CommandInterface
{
/**
* SchemaLintCommand constructor.
*
* @param YamlSchemaParser $parser
* @param TranslatorInterface|null $translator
*/
public function __construct(
protected YamlSchemaParser $parser,
protected ?TranslatorInterface $translator = null
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'schema:lint';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->t('command.schema_lint.description', 'Lint Pairity YAML table definitions in the schema directory.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$schemaPath = $args[0] ?? 'schema';
if (!is_dir($schemaPath)) {
echo $this->t('command.schema_lint.no_directory', 'Schema directory not found: {path}', ['path' => $schemaPath]) . "\n";
return 1;
}
$files = glob($schemaPath . '/*.yaml');
if (empty($files)) {
echo $this->t('command.schema_lint.no_files', 'No YAML schema files found in: {path}', ['path' => $schemaPath]) . "\n";
return 0;
}
$errors = 0;
foreach ($files as $file) {
try {
echo $this->t('command.schema_lint.checking', 'Linting {file}...', ['file' => $file]) . " ";
$this->parser->parseFile($file);
echo $this->t('command.schema_lint.ok', 'OK') . "\n";
} catch (SchemaException $e) {
echo $this->t('command.schema_lint.error', 'ERROR: {message}', ['message' => $e->getMessage()]) . "\n";
$errors++;
}
}
if ($errors > 0) {
echo "\n" . $this->t('command.schema_lint.finished_errors', 'Linting finished with {count} error(s).', ['count' => $errors]) . "\n";
return 1;
}
echo "\n" . $this->t('command.schema_lint.finished_success', 'All schema files are valid.') . "\n";
return 0;
}
/**
* Translate a message if a Translator is available.
*
* @param string $key
* @param string $default
* @param array $replace
* @return string
*/
protected function t(string $key, string $default, array $replace = []): string
{
if ($this->translator) {
return $this->translator->trans($key, $replace);
}
foreach ($replace as $placeholder => $value) {
$default = str_replace('{' . $placeholder . '}', (string) $value, $default);
}
return $default;
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
/**
* Class SeedCommand
*
* CLI command to seed the database with records.
*/
class SeedCommand implements CommandInterface
{
/**
* SeedCommand constructor.
*
* @param DatabaseManagerInterface $db
* @param TranslatorInterface $translator
*/
public function __construct(
protected DatabaseManagerInterface $db,
protected TranslatorInterface $translator
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'db:seed';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->translator->trans('command.db_seed.description', 'Seed the database with records.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
$class = $args[0] ?? 'DatabaseSeeder';
if (!str_contains($class, '\\')) {
$class = 'App\\Database\\Seeds\\' . $class;
}
if (!class_exists($class)) {
echo "Error: Seeder class [{$class}] not found.\n";
return 1;
}
echo "Seeding: {$class}...\n";
/** @var \Pairity\Database\Seeding\Seeder $seeder */
$seeder = new $class($this->db);
$seeder->run();
echo "Database seeding completed successfully.\n";
return 0;
}
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Pairity\Console\Commands;
use Pairity\Contracts\Console\CommandInterface;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\Contracts\Translation\TranslatorInterface;
/**
* Class SyncCheckCommand
*
* CLI command to verify synchronization of manual migration files and seed files.
*/
class SyncCheckCommand implements CommandInterface
{
/**
* SyncCheckCommand constructor.
*
* @param DatabaseManagerInterface $db
* @param TranslatorInterface $translator
*/
public function __construct(
protected DatabaseManagerInterface $db,
protected TranslatorInterface $translator
) {
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'db:check:sync';
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->translator->trans('command.db_check_sync.description', 'Verify synchronization of manual migration files and seed files.');
}
/**
* @inheritDoc
*/
public function execute(array $args, array $options): int
{
echo "Checking synchronization...\n";
// This is a stub implementation as we don't have a migrations table yet.
// In a real implementation, this would compare files in filesystem with records in a meta-table.
$migrationsDir = 'src/Database/Migrations';
$seedsDir = 'src/Database/Seeds';
$migrations = is_dir($migrationsDir) ? glob($migrationsDir . '/*.sql') : [];
$seeds = is_dir($seedsDir) ? glob($seedsDir . '/*.php') : [];
echo "Found " . count($migrations) . " manual migration(s).\n";
echo "Found " . count($seeds) . " seed(s).\n";
echo "Synchronization check completed. (Note: Full tracking requires migration table implementation in next phases).\n";
return 0;
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\MigrationGenerator;
class MakeMigrationCommand extends AbstractCommand
{
public function execute(array $args): void
{
$name = $args[0] ?? null;
if (!$name) {
$this->stderr('Missing migration Name. Usage: pairity make:migration CreateUsersTable [--path=DIR]');
exit(1);
}
$dir = $this->getMigrationsDir($args);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$generator = new MigrationGenerator($args['template'] ?? null);
$file = $generator->generate($name, $dir);
$this->stdout('Created: ' . $file);
}
}

View file

@ -1,38 +0,0 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\Migrator;
use Pairity\Migrations\MigrationLoader;
class MigrateCommand extends AbstractCommand
{
public function execute(array $args): void
{
$conn = $this->getConnection($args);
$dir = $this->getMigrationsDir($args);
$migrations = MigrationLoader::fromDirectory($dir);
if (!$migrations) {
$this->stdout('No migrations found in ' . $dir);
return;
}
$migrator = new Migrator($conn);
$migrator->setRegistry($migrations);
$pretend = isset($args['pretend']) && $args['pretend'];
$result = $migrator->migrate($migrations, $pretend);
if ($pretend) {
$this->stdout('SQL to be executed:');
foreach ($result as $log) {
$this->stdout($log['sql']);
if ($log['params']) {
$this->stdout(' Params: ' . json_encode($log['params']));
}
}
} else {
$this->stdout('Applied: ' . json_encode($result));
}
}
}

View file

@ -1,88 +0,0 @@
<?php
namespace Pairity\Console;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\IndexManager;
abstract class AbstractMongoCommand extends AbstractCommand
{
protected function getMongoConnection(array $args): \Pairity\NoSql\Mongo\MongoConnectionInterface
{
$config = $this->loadConfig($args);
return MongoConnectionManager::make($config);
}
}
class MongoIndexEnsureCommand extends AbstractMongoCommand
{
public function execute(array $args): void
{
$db = $args[0] ?? null;
$col = $args[1] ?? null;
$keysJson = $args[2] ?? null;
if (!$db || !$col || !$keysJson) {
$this->stderr('Usage: pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique]');
exit(1);
}
$keys = json_decode($keysJson, true);
if (!is_array($keys)) {
$this->stderr('Invalid KEYS_JSON');
exit(1);
}
$options = [];
if (isset($args['unique']) && $args['unique']) {
$options['unique'] = true;
}
$conn = $this->getMongoConnection($args);
$mgr = new IndexManager($conn, $db, $col);
$name = $mgr->ensureIndex($keys, $options);
$this->stdout("Index created: {$name}");
}
}
class MongoIndexDropCommand extends AbstractMongoCommand
{
public function execute(array $args): void
{
$db = $args[0] ?? null;
$col = $args[1] ?? null;
$name = $args[2] ?? null;
if (!$db || !$col || !$name) {
$this->stderr('Usage: pairity mongo:index:drop DB COLLECTION NAME');
exit(1);
}
$conn = $this->getMongoConnection($args);
$mgr = new IndexManager($conn, $db, $col);
$mgr->dropIndex($name);
$this->stdout("Index dropped: {$name}");
}
}
class MongoIndexListCommand extends AbstractMongoCommand
{
public function execute(array $args): void
{
$db = $args[0] ?? null;
$col = $args[1] ?? null;
if (!$db || !$col) {
$this->stderr('Usage: pairity mongo:index:list DB COLLECTION');
exit(1);
}
$conn = $this->getMongoConnection($args);
$mgr = new IndexManager($conn, $db, $col);
$indexes = $mgr->listIndexes();
$this->stdout(json_encode($indexes, JSON_PRETTY_PRINT));
}
}

View file

@ -1,42 +0,0 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\Migrator;
use Pairity\Migrations\MigrationLoader;
class ResetCommand extends AbstractCommand
{
public function execute(array $args): void
{
$conn = $this->getConnection($args);
$dir = $this->getMigrationsDir($args);
$migrations = MigrationLoader::fromDirectory($dir);
$migrator = new Migrator($conn);
$migrator->setRegistry($migrations);
$pretend = isset($args['pretend']) && $args['pretend'];
$totalResult = [];
while (true) {
$result = $migrator->rollback(1, $pretend);
if (!$result) {
break;
}
$totalResult = array_merge($totalResult, $result);
}
if ($pretend) {
$this->stdout('SQL to be executed:');
foreach ($totalResult as $log) {
$this->stdout($log['sql']);
if ($log['params']) {
$this->stdout(' Params: ' . json_encode($log['params']));
}
}
} else {
$this->stdout('Reset complete. Rolled back: ' . json_encode($totalResult));
}
}
}

View file

@ -1,36 +0,0 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\Migrator;
use Pairity\Migrations\MigrationLoader;
class RollbackCommand extends AbstractCommand
{
public function execute(array $args): void
{
$conn = $this->getConnection($args);
$dir = $this->getMigrationsDir($args);
$migrations = MigrationLoader::fromDirectory($dir);
$migrator = new Migrator($conn);
$migrator->setRegistry($migrations);
$steps = isset($args['steps']) ? max(1, (int)$args['steps']) : 1;
$pretend = isset($args['pretend']) && $args['pretend'];
$result = $migrator->rollback($steps, $pretend);
if ($pretend) {
$this->stdout('SQL to be executed:');
foreach ($result as $log) {
$this->stdout($log['sql']);
if ($log['params']) {
$this->stdout(' Params: ' . json_encode($log['params']));
}
}
} else {
$this->stdout('Rolled back: ' . json_encode($result));
}
}
}

View file

@ -1,23 +0,0 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\MigrationLoader;
use Pairity\Migrations\MigrationsRepository;
class StatusCommand extends AbstractCommand
{
public function execute(array $args): void
{
$conn = $this->getConnection($args);
$dir = $this->getMigrationsDir($args);
$migrations = array_keys(MigrationLoader::fromDirectory($dir));
$repo = new MigrationsRepository($conn);
$ran = $repo->getRan();
$pending = array_values(array_diff($migrations, $ran));
$this->stdout('Ran: ' . json_encode($ran));
$this->stdout('Pending: ' . json_encode($pending));
}
}

213
src/Container/Container.php Normal file
View file

@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace Pairity\Container;
use Pairity\Contracts\Container\ContainerInterface;
use ReflectionClass;
use ReflectionException;
use RuntimeException;
/**
* Class Container
*
* A simple, lightweight dependency injection container.
* Supports singleton and closure bindings, and basic auto-wiring via reflection.
*
* @package Pairity\Container
*/
class Container implements ContainerInterface
{
/**
* The registered bindings.
*
* @var array<string, array{concrete: mixed, shared: bool}>
*/
protected array $bindings = [];
/**
* The resolved singleton instances.
*
* @var array<string, mixed>
*/
protected array $instances = [];
/**
* @inheritDoc
*/
public function get(string $id): mixed
{
try {
return $this->make($id);
} catch (RuntimeException $e) {
throw $e;
}
}
/**
* @inheritDoc
*/
public function has(string $id): bool
{
return isset($this->bindings[$id]) || isset($this->instances[$id]) || class_exists($id);
}
/**
* @inheritDoc
*/
public function bind(string $abstract, mixed $concrete = null, bool $shared = false): void
{
if ($concrete === null) {
$concrete = $abstract;
}
$this->bindings[$abstract] = [
'concrete' => $concrete,
'shared' => $shared,
];
}
/**
* @inheritDoc
*/
public function singleton(string $abstract, mixed $concrete = null): void
{
$this->bind($abstract, $concrete, true);
}
/**
* @inheritDoc
*/
public function make(string $abstract): mixed
{
// If the instance is already resolved and shared, return it.
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
$concrete = $this->getConcrete($abstract);
// If the concrete is a closure or the same as abstract (needs instantiation)
if ($this->isBuildable($concrete, $abstract)) {
$object = $this->build($concrete);
} else {
$object = $this->make($concrete);
}
// If it's a shared binding, store the instance.
if ($this->isShared($abstract)) {
$this->instances[$abstract] = $object;
}
return $object;
}
/**
* Get the concrete type for a given abstract.
*
* @param string $abstract
* @return mixed
*/
protected function getConcrete(string $abstract): mixed
{
if (isset($this->bindings[$abstract])) {
return $this->bindings[$abstract]['concrete'];
}
return $abstract;
}
/**
* Determine if the given concrete is buildable.
*
* @param mixed $concrete
* @param string $abstract
* @return bool
*/
protected function isBuildable(mixed $concrete, string $abstract): bool
{
return $concrete === $abstract || $concrete instanceof \Closure;
}
/**
* Determine if a given abstract is shared.
*
* @param string $abstract
* @return bool
*/
protected function isShared(string $abstract): bool
{
return isset($this->bindings[$abstract]['shared']) && $this->bindings[$abstract]['shared'] === true;
}
/**
* Instantiate a concrete instance of the given type.
*
* @param mixed $concrete
* @return mixed
* @throws RuntimeException
*/
protected function build(mixed $concrete): mixed
{
if ($concrete instanceof \Closure) {
return $concrete($this);
}
try {
$reflector = new ReflectionClass($concrete);
} catch (ReflectionException $e) {
$translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations');
throw new RuntimeException($translator->trans('error.container_class_not_found', ['class' => $concrete]), 0, $e);
}
if (!$reflector->isInstantiable()) {
$translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations');
throw new RuntimeException($translator->trans('error.container_not_instantiable', ['class' => $concrete]));
}
$constructor = $reflector->getConstructor();
// If there is no constructor, we can just instantiate the class.
if ($constructor === null) {
return new $concrete();
}
$dependencies = $constructor->getParameters();
$instances = $this->resolveDependencies($dependencies);
return $reflector->newInstanceArgs($instances);
}
/**
* Resolve all of the dependencies from the ReflectionParameters.
*
* @param array $dependencies
* @return array
*/
protected function resolveDependencies(array $dependencies): array
{
$results = [];
foreach ($dependencies as $parameter) {
$type = $parameter->getType();
if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) {
if ($parameter->isDefaultValueAvailable()) {
$results[] = $parameter->getDefaultValue();
continue;
}
$translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations');
throw new RuntimeException($translator->trans('error.container_unresolvable', [
'parameter' => $parameter->getName(),
'class' => $parameter->getDeclaringClass()->getName()
]));
}
$results[] = $this->make($type->getName());
}
return $results;
}
}

View file

@ -1,30 +0,0 @@
<?php
namespace Pairity\Contracts;
use Psr\SimpleCache\CacheInterface;
interface CacheableDaoInterface extends DaoInterface
{
/**
* Set the cache instance for this DAO.
*/
public function setCache(CacheInterface $cache): static;
/**
* Get the cache instance.
*/
public function getCache(): ?CacheInterface;
/**
* Get cache configuration (enabled, ttl, prefix).
*
* @return array{enabled: bool, ttl: ?int, prefix: string}
*/
public function cacheConfig(): array;
/**
* Clear all cache entries related to this DAO/Table.
*/
public function clearCache(): bool;
}

View file

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

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Pairity\Contracts\Console;
/**
* Interface CommandInterface
*
* Defines the contract for all CLI commands in the Pairity tool.
* Every command must implement this interface to be registered and executed
* by the application console.
*
* @package Pairity\Contracts\Console
*/
interface CommandInterface
{
/**
* Get the name of the command (e.g., 'migrate', 'make:model').
*
* @return string
*/
public function getName(): string;
/**
* Get the description of what the command does.
*
* @return string
*/
public function getDescription(): string;
/**
* Execute the command logic.
*
* @param array $args Command line arguments.
* @param array $options Command line options (flags).
*
* @return int The exit code (0 for success, non-zero for error).
*/
public function execute(array $args, array $options): int;
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Pairity\Contracts\Container;
/**
* Interface ContainerInterface
*
* Defines the contract for the Pairity Service Container.
* This interface is loosely based on PSR-11 (ContainerInterface) to maintain
* standard compatibility while allowing for ORM-specific optimizations.
*
* @package Pairity\Contracts\Container
*/
interface ContainerInterface
{
/**
* Finds an entry of the container by its identifier and returns it.
*
* @param string $id Identifier of the entry to look for.
*
* @return mixed Entry.
*/
public function get(string $id): mixed;
/**
* Returns true if the container can return an entry for the given identifier.
* Returns false otherwise.
*
* @param string $id Identifier of the entry to look for.
*
* @return bool
*/
public function has(string $id): bool;
/**
* Register a binding with the container.
*
* @param string $abstract The abstract type (interface or class name).
* @param mixed $concrete The concrete implementation (closure, class name, or instance).
* @param bool $shared Whether the binding should be treated as a singleton.
*
* @return void
*/
public function bind(string $abstract, mixed $concrete = null, bool $shared = false): void;
/**
* Register a shared binding (singleton) with the container.
*
* @param string $abstract
* @param mixed $concrete
*
* @return void
*/
public function singleton(string $abstract, mixed $concrete = null): void;
/**
* Resolve the given type from the container.
*
* @param string $abstract
*
* @return mixed
*/
public function make(string $abstract): mixed;
}

View file

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

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Pairity\Contracts\Database;
use PDO;
use PDOStatement;
/**
* Interface ConnectionInterface
*
* Defines the contract for a database connection.
*
* @package Pairity\Contracts\Database
*/
interface ConnectionInterface
{
/**
* Get the underlying PDO instance for read operations.
*
* @return PDO
*/
public function getReadPdo(): PDO;
/**
* Get the underlying PDO instance for write operations.
*
* @return PDO
*/
public function getWritePdo(): PDO;
/**
* Execute a SQL statement and return the number of affected rows.
*
* @param string $query
* @param array $bindings
* @return int
*/
public function execute(string $query, array $bindings = []): int;
/**
* Execute a SELECT query and return the results.
*
* @param string $query
* @param array $bindings
* @return array
*/
public function select(string $query, array $bindings = []): array;
/**
* Run a raw SQL query.
*
* @param string $query
* @param array $bindings
* @return PDOStatement
*/
public function query(string $query, array $bindings = []): PDOStatement;
/**
* Check if the connection is healthy.
*
* @return bool
*/
public function checkHealth(): bool;
/**
* Get the connection name.
*
* @return string
*/
public function getName(): string;
/**
* Start a new database transaction.
*
* @return void
*/
public function beginTransaction(): void;
/**
* Commit the current transaction.
*
* @return void
*/
public function commit(): void;
/**
* Roll back the current transaction.
*
* @return void
*/
public function rollBack(): void;
/**
* Add an interceptor to the connection.
*
* @param InterceptorInterface $interceptor
* @return void
*/
public function addInterceptor(InterceptorInterface $interceptor): void;
/**
* Get the current transaction level.
*
* @return int
*/
public function transactionLevel(): int;
/**
* Get the driver instance.
*
* @return DriverInterface
*/
public function getDriver(): DriverInterface;
/**
* Get the connection configuration.
*
* @return array
*/
public function getConfig(): array;
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Pairity\Contracts\Database;
/**
* Interface DatabaseManagerInterface
*
* Defines the contract for managing multiple database connections.
*
* @package Pairity\Contracts\Database
*/
interface DatabaseManagerInterface
{
/**
* Get a database connection instance.
*
* @param string|null $name The connection name (defaults to default connection).
* @return ConnectionInterface
*/
public function connection(?string $name = null): ConnectionInterface;
/**
* Reconnect to the given database.
*
* @param string|null $name
* @return ConnectionInterface
*/
public function reconnect(?string $name = null): ConnectionInterface;
/**
* Disconnect from the given database.
*
* @param string|null $name
* @return void
*/
public function disconnect(?string $name = null): void;
/**
* Get the default connection name.
*
* @return string
*/
public function getDefaultConnection(): string;
/**
* Set the default connection name.
*
* @param string $name
* @return void
*/
public function setDefaultConnection(string $name): void;
/**
* Get the query grammar for a driver.
*
* @param string $driver
* @return \Pairity\Database\Query\Grammar
*/
public function getQueryGrammar(string $driver): \Pairity\Database\Query\Grammar;
/**
* Get the event dispatcher instance.
*
* @return \Pairity\Contracts\Events\DispatcherInterface
*/
public function getDispatcher(): \Pairity\Contracts\Events\DispatcherInterface;
/**
* Set the event dispatcher instance.
*
* @param \Pairity\Contracts\Events\DispatcherInterface $dispatcher
* @return void
*/
public function setDispatcher(\Pairity\Contracts\Events\DispatcherInterface $dispatcher): void;
/**
* Get the Unit of Work instance.
*
* @return \Pairity\Database\UnitOfWork
*/
public function unitOfWork(): \Pairity\Database\UnitOfWork;
/**
* Get the container instance.
*
* @return \Pairity\Contracts\Container\ContainerInterface
*/
public function getContainer(): \Pairity\Contracts\Container\ContainerInterface;
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pairity\Contracts\Database;
use PDO;
/**
* Interface DriverInterface
*
* Defines the contract for a database driver.
*
* @package Pairity\Contracts\Database
*/
interface DriverInterface
{
/**
* Create a new PDO instance based on the configuration.
*
* @param array $config
* @return PDO
*/
public function connect(array $config): PDO;
/**
* Get the driver name (e.g., 'mysql', 'sqlite').
*
* @return string
*/
public function getName(): string;
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pairity\Contracts\Database;
/**
* Interface InterceptorInterface
*
* Defines the contract for a database query interceptor (middleware).
*
* @package Pairity\Contracts\Database
*/
interface InterceptorInterface
{
/**
* Intercept a database operation.
*
* @param string $query The SQL query.
* @param array $bindings The query bindings.
* @param string $mode The connection mode (read/write).
* @param callable $next The next interceptor or the final query execution.
* @return mixed
*/
public function intercept(string $query, array $bindings, string $mode, callable $next): mixed;
}

View file

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

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Pairity\Contracts\Events;
/**
* Interface DispatcherInterface
*
* Defines the contract for the Pairity Event Dispatcher.
*/
interface DispatcherInterface
{
/**
* Register an event listener.
*
* @param string $event
* @param callable $listener
* @return void
*/
public function listen(string $event, callable $listener): void;
/**
* Dispatch an event and call the listeners.
*
* @param string $event
* @param mixed $payload
* @param bool $halt
* @return mixed
*/
public function dispatch(string $event, mixed $payload = null, bool $halt = false): mixed;
/**
* Determine if a given event has listeners.
*
* @param string $event
* @return bool
*/
public function hasListeners(string $event): bool;
}

View file

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

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pairity\Contracts\Schema;
/**
* Interface HydratorInterface
*
* Defines the contract for high-performance DTO hydrators.
*/
interface HydratorInterface
{
/**
* Hydrate a DTO instance from raw data.
*
* @param array<string, mixed> $data
* @param object $instance The DTO instance to hydrate.
* @return void
*/
public function hydrate(array $data, object $instance): void;
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Pairity\Contracts\Translation;
/**
* Interface TranslatorInterface
*
* Defines the contract for the Pairity Translation service.
*
* @package Pairity\Contracts\Translation
*/
interface TranslatorInterface
{
/**
* Translate the given message.
*
* @param string $key The translation key.
* @param array<string, mixed> $replace The values to replace in the message.
* @param string|null $locale The locale to use (defaults to the current locale).
* @return string The translated message.
*/
public function trans(string $key, array $replace = [], ?string $locale = null): string;
/**
* Get the current locale.
*
* @return string
*/
public function getLocale(): string;
/**
* Set the current locale.
*
* @param string $locale
* @return void
*/
public function setLocale(string $locale): void;
}

317
src/DAO/BaseDAO.php Normal file
View file

@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace Pairity\DAO;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\DTO\IdentityMap;
/**
* Class BaseDAO
*
* Base class for all generated DAOs.
*
* @package Pairity\DAO
*/
abstract class BaseDAO
{
/**
* @var string The table name.
*/
protected string $table;
/**
* @var string The primary key.
*/
protected string $primaryKey = 'id';
/**
* @var string The connection name.
*/
protected string $connection = 'default';
/**
* @var array<string, mixed>
*/
protected array $options = [];
/**
* @var \Pairity\Contracts\Schema\HydratorInterface|null
*/
protected ?\Pairity\Contracts\Schema\HydratorInterface $hydrator = null;
/**
* @var string|null
*/
protected ?string $dtoClass = null;
/**
* @var string|null
*/
protected ?string $lockingColumn = null;
/**
* @var bool
*/
protected bool $auditable = false;
/**
* BaseDAO constructor.
*
* @param DatabaseManagerInterface $db
* @param IdentityMap $identityMap
*/
public function __construct(
protected DatabaseManagerInterface $db,
protected IdentityMap $identityMap
) {
}
/**
* Set the hydrator for the DAO.
*
* @param \Pairity\Contracts\Schema\HydratorInterface $hydrator
* @return void
*/
public function setHydrator(\Pairity\Contracts\Schema\HydratorInterface $hydrator): void
{
$this->hydrator = $hydrator;
}
/**
* Get the connection instance.
*
* @return \Pairity\Contracts\Database\ConnectionInterface
*/
protected function getConnection(): \Pairity\Contracts\Database\ConnectionInterface
{
return $this->db->connection($this->connection);
}
/**
* Create a new query builder for the table.
*
* @return \Pairity\Database\Query\Builder
*/
public function query(): \Pairity\Database\Query\Builder
{
$connection = $this->getConnection();
$grammar = $this->db->getQueryGrammar($connection->getDriver()->getName());
$builder = new \Pairity\Database\Query\Builder($this->db, $connection, $grammar);
if ($this->dtoClass) {
$builder->setModel($this->dtoClass, $this);
}
return $builder->from($this->table);
}
/**
* @return string
*/
public function getTable(): string
{
return $this->table;
}
/**
* Get the connection name.
*
* @return string
*/
public function getConnectionName(): string
{
return $this->connection;
}
/**
* @return string|null
*/
public function getDtoClass(): ?string
{
return $this->dtoClass;
}
/**
* @return string
*/
public function getPrimaryKey(): string
{
return $this->primaryKey;
}
/**
* Get a DAO option.
*
* @param string $key
* @param mixed|null $default
* @return mixed
*/
public function getOption(string $key, mixed $default = null): mixed
{
return $this->options[$key] ?? $default;
}
/**
* @return \Pairity\Contracts\Schema\HydratorInterface|null
*/
public function getHydrator(): ?\Pairity\Contracts\Schema\HydratorInterface
{
return $this->hydrator;
}
/**
* @return IdentityMap
*/
public function getIdentityMap(): IdentityMap
{
return $this->identityMap;
}
/**
* Delete a record by its primary key.
*
* @param mixed $id
* @return int
*/
public function delete(mixed $id): int
{
return $this->getConnection()
->execute("DELETE FROM {$this->table} WHERE {$this->primaryKey} = ?", [$id]);
}
/**
* Fire the given event for the model.
*
* @param string $event
* @param mixed $payload
* @param bool $halt
* @return mixed
*/
protected function fireModelEvent(string $event, mixed $payload = null, bool $halt = true): mixed
{
$dispatcher = $this->db->getDispatcher();
$fullEventName = "pairity.model.{$event}: " . get_class($payload ?? $this);
return $dispatcher->dispatch($fullEventName, $payload, $halt);
}
/**
* Get the database manager.
*
* @return DatabaseManagerInterface
*/
public function getDb(): DatabaseManagerInterface
{
return $this->db;
}
/**
* Save a DTO instance.
*
* @param \Pairity\DTO\BaseDTO $dto
* @return bool
* @throws \Pairity\Exceptions\DatabaseException
*/
public function save(\Pairity\DTO\BaseDTO $dto): bool
{
$data = $dto->toArray();
$primaryKeyValue = $data[$this->primaryKey] ?? null;
if ($primaryKeyValue === null) {
return $this->insert($data);
}
return $this->update($primaryKeyValue, $data, $dto);
}
/**
* Insert a new record.
*
* @param array<string, mixed> $data
* @return bool
*/
protected function insert(array $data): bool
{
if ($this->getOption('tenancy', false)) {
$tenantColumn = \Pairity\Database\Query\Scopes\TenantScope::getTenantColumn();
if (!isset($data[$tenantColumn]) && ($tenantId = \Pairity\Database\Query\Scopes\TenantScope::getTenantId())) {
$data[$tenantColumn] = $tenantId;
}
}
if ($this->lockingColumn && !isset($data[$this->lockingColumn])) {
$data[$this->lockingColumn] = 1;
}
$grammar = $this->db->getQueryGrammar($this->getConnection()->getDriver()->getName());
$columns = implode(', ', array_map([$grammar, 'wrap'], array_keys($data)));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
return $this->getConnection()->execute($sql, array_values($data)) > 0;
}
/**
* Update an existing record.
*
* @param mixed $id
* @param array<string, mixed> $data
* @param \Pairity\DTO\BaseDTO|null $dto
* @return bool
* @throws \Pairity\Exceptions\DatabaseException
*/
protected function update(mixed $id, array $data, ?\Pairity\DTO\BaseDTO $dto = null): bool
{
$sets = [];
$values = [];
$grammar = $this->db->getQueryGrammar($this->getConnection()->getDriver()->getName());
$currentVersion = null;
if ($this->lockingColumn) {
$currentVersion = $data[$this->lockingColumn] ?? null;
$data[$this->lockingColumn] = ($currentVersion ?: 1) + 1;
}
foreach ($data as $column => $value) {
if ($column === $this->primaryKey) continue;
$sets[] = $grammar->wrap($column) . " = ?";
$values[] = $value;
}
$values[] = $id;
$sql = "UPDATE " . $grammar->wrapTable($this->table) . " SET " . implode(', ', $sets) . " WHERE " . $grammar->wrap($this->primaryKey) . " = ?";
if ($this->lockingColumn && $currentVersion !== null) {
$sql .= " AND " . $grammar->wrap($this->lockingColumn) . " = ?";
$values[] = $currentVersion;
}
$affected = $this->getConnection()->execute($sql, $values);
if ($this->lockingColumn && $affected === 0) {
throw new \Pairity\Exceptions\DatabaseException("Optimistic locking failed for table [{$this->table}] and ID [{$id}].");
}
if ($dto && $this->lockingColumn) {
$dto->setAttribute($this->lockingColumn, $data[$this->lockingColumn]);
}
return $affected > 0;
}
/**
* Update or insert records.
*
* @param array $values
* @param array $uniqueBy
* @param array|null $update
* @return int
*/
public function upsert(array $values, array $uniqueBy, array $update = null): int
{
return $this->query()->upsert($values, $uniqueBy, $update);
}
}

146
src/DTO/BaseDTO.php Normal file
View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Pairity\DTO;
/**
* Class BaseDTO
*
* Base class for all generated DTOs.
*
* @package Pairity\DTO
*/
abstract class BaseDTO
{
/**
* @var array<string, mixed>
*/
protected array $attributes = [];
/**
* @var bool
*/
protected bool $isProxy = false;
/**
* @var array<string, mixed>
*/
protected array $relations = [];
/**
* @var \Pairity\DAO\BaseDAO|null
*/
protected ?\Pairity\DAO\BaseDAO $dao = null;
/**
* BaseDTO constructor.
*
* @param array<string, mixed> $attributes
*/
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
if ($this instanceof ProxyInterface) {
$this->isProxy = true;
}
}
/**
* Ensure the object is fully loaded if it is a proxy.
*
* @return void
*/
protected function ensureLoaded(): void
{
if ($this->isProxy && $this instanceof ProxyInterface && !$this->__isInitialized()) {
$this->__load();
}
}
/**
* Set a relationship on the DTO.
*
* @param string $relation
* @param mixed $value
* @return void
*/
public function setRelation(string $relation, mixed $value): void
{
$this->relations[$relation] = $value;
}
/**
* Get a relationship from the DTO.
*
* @param string $relation
* @return mixed
*/
public function getRelation(string $relation): mixed
{
if (!$this->relationLoaded($relation) && $this->dao && method_exists($this->dao, $relation)) {
$this->setRelation($relation, $this->dao->{$relation}($this)->getResults());
}
return $this->relations[$relation] ?? null;
}
/**
* Check if a relationship is loaded.
*
* @param string $relation
* @return bool
*/
public function relationLoaded(string $relation): bool
{
return array_key_exists($relation, $this->relations);
}
/**
* Set the DAO instance for the DTO.
*
* @param \Pairity\DAO\BaseDAO $dao
* @return void
*/
public function setDao(\Pairity\DAO\BaseDAO $dao): void
{
$this->dao = $dao;
}
/**
* Get the DAO instance for the DTO.
*
* @return \Pairity\DAO\BaseDAO|null
*/
public function getDao(): ?\Pairity\DAO\BaseDAO
{
return $this->dao;
}
/**
* Convert the DTO to an array.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$this->ensureLoaded();
return $this->attributes;
}
/**
* Set an attribute on the DTO.
*
* @param string $key
* @param mixed $value
* @return void
*/
public function setAttribute(string $key, mixed $value): void
{
$this->attributes[$key] = $value;
if ($this->dao) {
$this->dao->getDb()->unitOfWork()->registerDirty($this);
}
}
}

65
src/DTO/IdentityMap.php Normal file
View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Pairity\DTO;
use WeakMap;
/**
* Class IdentityMap
*
* Tracks DTO instances by their class and primary key to maintain object identity.
* Uses a WeakMap to allow garbage collection of DTOs not referenced elsewhere.
*/
class IdentityMap
{
/**
* @var array<string, WeakMap<object, bool>>
*/
protected array $map = [];
/**
* @var array<string, array<string|int, object>>
*/
protected array $strongRefs = [];
/**
* Get a DTO from the map.
*
* @param string $className
* @param string|int $id
* @return object|null
*/
public function get(string $className, string|int $id): ?object
{
return $this->strongRefs[$className][$id] ?? null;
}
/**
* Add a DTO to the map.
*
* @param string $className
* @param string|int $id
* @param object $instance
* @return void
*/
public function add(string $className, string|int $id, object $instance): void
{
if (!isset($this->strongRefs[$className])) {
$this->strongRefs[$className] = [];
}
$this->strongRefs[$className][$id] = $instance;
}
/**
* Clear the identity map.
*
* @return void
*/
public function clear(): void
{
$this->strongRefs = [];
}
}

103
src/DTO/ProxyFactory.php Normal file
View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Pairity\DTO;
use Pairity\DAO\BaseDAO;
use RuntimeException;
/**
* Class ProxyFactory
*
* Generates and instantiates lazy-loading proxy classes for DTOs.
*/
class ProxyFactory
{
/**
* @var array<string, string> Cache of generated proxy class names.
*/
protected static array $proxyClasses = [];
/**
* Create a new proxy instance.
*
* @param string $dtoClass
* @param BaseDAO $dao
* @param mixed $id
* @return object
*/
public function create(string $dtoClass, BaseDAO $dao, mixed $id): object
{
$proxyClass = $this->getProxyClass($dtoClass);
return new $proxyClass($dao, $id);
}
/**
* Get or generate the proxy class name for a DTO.
*
* @param string $dtoClass
* @return string
*/
protected function getProxyClass(string $dtoClass): string
{
if (isset(self::$proxyClasses[$dtoClass])) {
return self::$proxyClasses[$dtoClass];
}
$proxyClassName = 'Pairity_Proxy_' . md5($dtoClass);
if (!class_exists($proxyClassName)) {
$this->generateProxyClass($dtoClass, $proxyClassName);
}
return self::$proxyClasses[$dtoClass] = $proxyClassName;
}
/**
* Generate the proxy class code and eval it.
*
* @param string $dtoClass
* @param string $proxyClassName
* @return void
*/
protected function generateProxyClass(string $dtoClass, string $proxyClassName): void
{
$code = <<<PHP
class {$proxyClassName} extends \\{$dtoClass} implements \\Pairity\\DTO\\ProxyInterface
{
protected bool \$__initialized = false;
protected \\Pairity\\DAO\\BaseDAO \$__dao;
protected \$__id;
public function __construct(\\Pairity\\DAO\\BaseDAO \$dao, \$id)
{
parent::__construct(['id' => \$id]);
\$this->__dao = \$dao;
\$this->__id = \$id;
}
public function __load(): void
{
if (\$this->__initialized) return;
\$this->__initialized = true;
\$fullDto = \$this->__dao->find(\$this->__id);
if (\$fullDto) {
foreach (\$fullDto->toArray() as \$key => \$value) {
\$this->\$key = \$value;
}
}
}
public function __isInitialized(): bool
{
return \$this->__initialized;
}
}
PHP;
eval($code);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Pairity\DTO;
/**
* Interface ProxyInterface
*
* Defines the contract for lazy-loading ghost objects.
*/
interface ProxyInterface
{
/**
* Load the full state of the proxy.
*
* @return void
*/
public function __load(): void;
/**
* Check if the proxy has been initialized.
*
* @return bool
*/
public function __isInitialized(): bool;
}

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Auditing;
use Pairity\DTO\BaseDTO;
use Pairity\Contracts\Events\DispatcherInterface;
/**
* Class AuditListener
*
* Listens to model events and triggers auditing when applicable.
*/
class AuditListener
{
/**
* AuditListener constructor.
*
* @param Auditor $auditor
*/
public function __construct(
protected Auditor $auditor
) {
}
/**
* Register the listeners for the subscriber.
*
* @param DispatcherInterface $dispatcher
* @return void
*/
public function subscribe(DispatcherInterface $dispatcher): void
{
$dispatcher->listen('pairity.model.created: *', [$this, 'onCreated']);
$dispatcher->listen('pairity.model.updated: *', [$this, 'onUpdated']);
$dispatcher->listen('pairity.model.deleted: *', [$this, 'onDeleted']);
}
/**
* Handle model created event.
*
* @param BaseDTO $dto
* @return void
*/
public function onCreated(BaseDTO $dto): void
{
$this->audit($dto, 'created');
}
/**
* Handle model updated event.
*
* @param BaseDTO $dto
* @return void
*/
public function onUpdated(BaseDTO $dto): void
{
$this->audit($dto, 'updated');
}
/**
* Handle model deleted event.
*
* @param BaseDTO $dto
* @return void
*/
public function onDeleted(BaseDTO $dto): void
{
$this->audit($dto, 'deleted');
}
/**
* Trigger audit recording if the model is auditable.
*
* @param BaseDTO $dto
* @param string $event
* @return void
*/
protected function audit(BaseDTO $dto, string $event): void
{
$dao = $dto->getDao();
if ($dao && $dao->getOption('auditable', false)) {
$changes = $this->auditor->getChanges($dto);
$this->auditor->record($dto, $event, $changes['old'], $changes['new']);
}
}
}

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Auditing;
use Pairity\DTO\BaseDTO;
/**
* Class Auditor
*
* Handles calculating diffs and logging changes for auditable models.
*/
class Auditor
{
/**
* Calculate changes between a DTO's attributes and its current state.
* Note: In a full implementation, we might track 'original' values in the DTO.
* For now, we'll assume we are logging the state as it is at the moment of the event.
*
* @param BaseDTO $dto
* @return array
*/
public function getChanges(BaseDTO $dto): array
{
// Simple implementation: return all attributes as 'new'
// In Milestone 15/future, we should add 'original' attribute tracking to BaseDTO.
return [
'old' => [],
'new' => $dto->toArray()
];
}
/**
* Record an audit entry.
*
* @param BaseDTO $dto
* @param string $event
* @param array $oldValues
* @param array $newValues
* @return void
*/
public function record(BaseDTO $dto, string $event, array $oldValues, array $newValues): void
{
$dao = $dto->getDao();
if (!$dao) {
return;
}
$db = $dao->getDb();
$connection = $db->connection();
$primaryKey = $dao->getPrimaryKey();
$auditableId = $dto->toArray()[$primaryKey] ?? null;
$sql = "INSERT INTO audits (auditable_type, auditable_id, event, old_values, new_values, created_at) VALUES (?, ?, ?, ?, ?, ?)";
$connection->execute($sql, [
get_class($dto),
$auditableId,
$event,
json_encode($oldValues),
json_encode($newValues),
date('Y-m-d H:i:s')
]);
}
}

343
src/Database/Connection.php Normal file
View file

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace Pairity\Database;
use Pairity\Contracts\Database\ConnectionInterface;
use Pairity\Contracts\Database\DriverInterface;
use Pairity\Contracts\Database\InterceptorInterface;
use Pairity\Exceptions\QueryException;
use PDO;
use PDOException;
use PDOStatement;
/**
* Class Connection
*
* Wraps a PDO instance and provides database access methods.
*
* @package Pairity\Database
*/
class Connection implements ConnectionInterface
{
/**
* @var PDO|null
*/
protected ?PDO $readPdo = null;
/**
* @var PDO|null
*/
protected ?PDO $writePdo = null;
/**
* @var bool Whether a write has occurred during this request.
*/
protected bool $sticky = false;
/**
* @var int The current transaction level.
*/
protected int $transactions = 0;
/**
* @var array<InterceptorInterface> Registered interceptors.
*/
protected array $interceptors = [];
/**
* Connection constructor.
*
* @param string $name
* @param DriverInterface $driver
* @param array $config
*/
public function __construct(
protected string $name,
protected DriverInterface $driver,
protected array $config
) {
}
/**
* @inheritDoc
*/
public function getReadPdo(): PDO
{
if ($this->sticky && $this->writePdo) {
return $this->writePdo;
}
if ($this->readPdo === null) {
$this->readPdo = $this->createPdo($this->getReadConfig());
}
return $this->readPdo;
}
/**
* @inheritDoc
*/
public function getWritePdo(): PDO
{
if ($this->writePdo === null) {
$this->writePdo = $this->createPdo($this->getWriteConfig());
}
return $this->writePdo;
}
/**
* Create a new PDO instance from the given config.
*
* @param array $config
* @return PDO
* @throws QueryException
*/
protected function createPdo(array $config): PDO
{
try {
return $this->driver->connect($config);
} catch (PDOException $e) {
throw new QueryException(
'',
[],
"Failed to connect to database: " . $e->getMessage(),
(int) $e->getCode(),
$e
);
}
}
/**
* Get the configuration for a read connection.
*
* @return array
*/
protected function getReadConfig(): array
{
$config = $this->config['read'] ?? $this->config;
if (isset($config[0])) {
return array_merge($this->config, $config[array_rand($config)]);
}
return array_merge($this->config, $config);
}
/**
* Get the configuration for a write connection.
*
* @return array
*/
protected function getWriteConfig(): array
{
$config = $this->config['write'] ?? $this->config;
if (isset($config[0])) {
return array_merge($this->config, $config[array_rand($config)]);
}
return array_merge($this->config, $config);
}
/**
* @inheritDoc
*/
public function execute(string $query, array $bindings = []): int
{
return $this->run($query, $bindings, function (PDOStatement $statement) {
$this->sticky = true;
return $statement->rowCount();
}, 'write');
}
/**
* @inheritDoc
*/
public function select(string $query, array $bindings = []): array
{
return $this->run($query, $bindings, function (PDOStatement $statement) {
return $statement->fetchAll(PDO::FETCH_ASSOC);
}, 'read');
}
/**
* @inheritDoc
*/
public function query(string $query, array $bindings = []): PDOStatement
{
// For raw query, we default to write if we're not sure,
// but typically raw queries should be handled carefully.
// Let's stick with read by default if it's not a modifying query,
// but for now let's just use write to be safe or allow user to specify?
// SPECS say select for read, execute for write. query() is raw.
// Let's assume raw query is write.
return $this->run($query, $bindings, function (PDOStatement $statement) {
return $statement;
}, 'write');
}
/**
* Run a query and handle exceptions.
*
* @param string $query
* @param array $bindings
* @param callable $callback
* @param string $mode
* @return mixed
* @throws QueryException
*/
protected function run(string $query, array $bindings, callable $callback, string $mode = 'write'): mixed
{
$pipeline = $this->createPipeline($callback);
return $pipeline($query, $bindings, $mode);
}
/**
* Create the interceptor pipeline.
*
* @param callable $final
* @return callable
*/
protected function createPipeline(callable $final): callable
{
$pipeline = function (string $query, array $bindings, string $mode) use ($final) {
try {
$pdo = ($mode === 'write') ? $this->getWritePdo() : $this->getReadPdo();
$statement = $pdo->prepare($query);
$statement->execute($bindings);
return $final($statement);
} catch (PDOException $e) {
throw new QueryException(
$query,
$bindings,
$e->getMessage(),
(int) $e->getCode(),
$e
);
}
};
foreach (array_reverse($this->interceptors) as $interceptor) {
$next = $pipeline;
$pipeline = function (string $query, array $bindings, string $mode) use ($interceptor, $next) {
return $interceptor->intercept($query, $bindings, $mode, $next);
};
}
return $pipeline;
}
/**
* @inheritDoc
*/
public function checkHealth(): bool
{
try {
$this->select('SELECT 1');
return true;
} catch (PDOException|QueryException $e) {
return false;
}
}
/**
* @inheritDoc
*/
public function beginTransaction(): void
{
$pdo = $this->getWritePdo();
if ($this->transactions === 0) {
$pdo->beginTransaction();
} elseif ($this->transactions > 0) {
$pdo->exec("SAVEPOINT parity_{$this->transactions}");
}
$this->transactions++;
$this->sticky = true;
}
/**
* @inheritDoc
*/
public function commit(): void
{
if ($this->transactions === 1) {
$this->getWritePdo()->commit();
} elseif ($this->transactions > 1) {
$this->getWritePdo()->exec("RELEASE SAVEPOINT parity_" . ($this->transactions - 1));
}
$this->transactions = max(0, $this->transactions - 1);
}
/**
* @inheritDoc
*/
public function rollBack(): void
{
if ($this->transactions === 1) {
$this->getWritePdo()->rollBack();
} elseif ($this->transactions > 1) {
$this->getWritePdo()->exec("ROLLBACK TO SAVEPOINT parity_" . ($this->transactions - 1));
}
$this->transactions = max(0, $this->transactions - 1);
}
/**
* @inheritDoc
*/
public function addInterceptor(InterceptorInterface $interceptor): void
{
$this->interceptors[] = $interceptor;
}
/**
* @inheritDoc
*/
public function transactionLevel(): int
{
return $this->transactions;
}
/**
* @inheritDoc
*/
public function getName(): string
{
return $this->name;
}
/**
* @inheritDoc
*/
public function getDriver(): DriverInterface
{
return $this->driver;
}
/**
* @inheritDoc
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Disconnect the PDO instances.
*
* @return void
*/
public function disconnect(): void
{
$this->readPdo = null;
$this->writePdo = null;
$this->sticky = false;
}
}

View file

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

View file

@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace Pairity\Database;
use Pairity\Contracts\Database\ConnectionInterface;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\Contracts\Database\DriverInterface;
use Pairity\Database\Drivers\MySQLDriver;
use Pairity\Database\Drivers\OracleDriver;
use Pairity\Database\Drivers\PostgresDriver;
use Pairity\Database\Drivers\SQLiteDriver;
use Pairity\Database\Drivers\SqlServerDriver;
use Pairity\Exceptions\PairityException;
use RuntimeException;
/**
* Class DatabaseManager
*
* Manages database connections and their lifecycles.
*
* @package Pairity\Database
*/
class DatabaseManager implements DatabaseManagerInterface
{
/**
* @var array<string, ConnectionInterface>
*/
protected array $connections = [];
/**
* @var array<string, DriverInterface>
*/
protected array $drivers = [];
/**
* @var \Pairity\Contracts\Container\ContainerInterface|null
*/
protected ?\Pairity\Contracts\Container\ContainerInterface $container = null;
/**
* @var UnitOfWork|null
*/
protected ?UnitOfWork $unitOfWork = null;
/**
* @var \Pairity\Contracts\Events\DispatcherInterface|null
*/
protected ?\Pairity\Contracts\Events\DispatcherInterface $dispatcher = null;
/**
* DatabaseManager constructor.
*
* @param array $config The database configuration.
* @param \Pairity\Contracts\Container\ContainerInterface|null $container
*/
public function __construct(
protected array $config,
?\Pairity\Contracts\Container\ContainerInterface $container = null
) {
$this->container = $container;
}
/**
* @inheritDoc
*/
public function getDispatcher(): \Pairity\Contracts\Events\DispatcherInterface
{
if (!$this->dispatcher) {
$this->dispatcher = new \Pairity\Events\Dispatcher();
}
return $this->dispatcher;
}
/**
* @inheritDoc
*/
public function setDispatcher(\Pairity\Contracts\Events\DispatcherInterface $dispatcher): void
{
$this->dispatcher = $dispatcher;
}
/**
* Get the Unit of Work instance.
*
* @return UnitOfWork
*/
public function unitOfWork(): UnitOfWork
{
if (!$this->unitOfWork) {
$this->unitOfWork = new UnitOfWork($this);
}
return $this->unitOfWork;
}
/**
* Set the container instance.
*
* @param \Pairity\Contracts\Container\ContainerInterface $container
* @return void
*/
public function setContainer(\Pairity\Contracts\Container\ContainerInterface $container): void
{
$this->container = $container;
}
/**
* @inheritDoc
*/
public function getContainer(): \Pairity\Contracts\Container\ContainerInterface
{
if (!$this->container) {
$translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations');
throw new RuntimeException($translator->trans('error.container_not_set'));
}
return $this->container;
}
/**
* @inheritDoc
*/
public function connection(?string $name = null): ConnectionInterface
{
$name = $name ?: $this->getDefaultConnection();
if (!isset($this->connections[$name])) {
$this->connections[$name] = $this->makeConnection($name);
}
return $this->connections[$name];
}
/**
* @inheritDoc
*/
public function reconnect(?string $name = null): ConnectionInterface
{
$name = $name ?: $this->getDefaultConnection();
$this->disconnect($name);
return $this->connection($name);
}
/**
* @inheritDoc
*/
public function disconnect(?string $name = null): void
{
$name = $name ?: $this->getDefaultConnection();
if (isset($this->connections[$name])) {
$this->connections[$name]->disconnect();
unset($this->connections[$name]);
}
}
/**
* @inheritDoc
*/
public function getDefaultConnection(): string
{
return $this->config['default'] ?? 'default';
}
/**
* @inheritDoc
*/
public function setDefaultConnection(string $name): void
{
$this->config['default'] = $name;
}
/**
* Resolve a connection instance.
*
* @param string $name
* @return ConnectionInterface
* @throws RuntimeException
*/
protected function makeConnection(string $name): ConnectionInterface
{
$config = $this->getConnectionConfig($name);
$driverName = $config['driver'] ?? 'sqlite';
$driver = $this->resolveDriver($driverName);
return new Connection($name, $driver, $config);
}
/**
* Get the configuration for a connection.
*
* @param string $name
* @return array
* @throws RuntimeException
*/
protected function getConnectionConfig(string $name): array
{
$connections = $this->config['connections'] ?? [];
if (!isset($connections[$name])) {
throw new RuntimeException("Database connection [{$name}] not configured.");
}
return $connections[$name];
}
/**
* Resolve the driver instance.
*
* @param string $name
* @return DriverInterface
* @throws RuntimeException
*/
protected function resolveDriver(string $name): DriverInterface
{
if (isset($this->drivers[$name])) {
return $this->drivers[$name];
}
$driver = match ($name) {
'sqlite' => new SQLiteDriver(),
'mysql' => new MySQLDriver(),
'pgsql', 'postgres' => new PostgresDriver(),
'sqlsrv', 'sqlserver' => new SqlServerDriver(),
'oci', 'oracle' => new OracleDriver(),
default => throw new RuntimeException("Database driver [{$name}] not supported."),
};
return $this->drivers[$name] = $driver;
}
/**
* Get the query grammar for a driver.
*
* @param string $driver
* @return \Pairity\Database\Query\Grammar
*/
public function getQueryGrammar(string $driver): \Pairity\Database\Query\Grammar
{
return match ($driver) {
'mysql' => new \Pairity\Database\Query\Grammars\MySqlGrammar(),
'pgsql', 'postgres' => new \Pairity\Database\Query\Grammars\PostgresGrammar(),
'sqlsrv', 'sqlserver' => new \Pairity\Database\Query\Grammars\SqlServerGrammar(),
'oci', 'oracle' => new \Pairity\Database\Query\Grammars\OracleGrammar(),
'sqlite' => new \Pairity\Database\Query\Grammars\SqliteGrammar(),
default => (function() use ($driver) {
$translator = $this->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class);
throw new \Pairity\Exceptions\DatabaseException(
$translator->trans('error.driver_not_supported', ['driver' => $driver]),
0,
null,
['driver' => $driver]
);
})(),
};
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Drivers;
use Pairity\Contracts\Database\DriverInterface;
use PDO;
/**
* Class AbstractDriver
*
* Base class for database drivers.
*
* @package Pairity\Database\Drivers
*/
abstract class AbstractDriver implements DriverInterface
{
/**
* Default PDO options.
*
* @var array
*/
protected array $options = [
PDO::ATTR_CASE => PDO::CASE_NATURAL,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL,
PDO::ATTR_STRINGIFY_FETCHES => false,
PDO::ATTR_EMULATE_PREPARES => false,
];
/**
* @inheritDoc
*/
public function connect(array $config): PDO
{
$dsn = $this->buildDsn($config);
$username = $config['username'] ?? null;
$password = $config['password'] ?? null;
$options = array_replace($this->options, $config['options'] ?? []);
return new PDO($dsn, $username, $password, $options);
}
/**
* Build the DSN string for the driver.
*
* @param array $config
* @return string
*/
abstract protected function buildDsn(array $config): string;
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Drivers;
/**
* Class MySQLDriver
*
* Driver for MySQL databases.
*
* @package Pairity\Database\Drivers
*/
class MySQLDriver extends AbstractDriver
{
/**
* @inheritDoc
*/
protected function buildDsn(array $config): string
{
$host = $config['host'] ?? '127.0.0.1';
$port = $config['port'] ?? 3306;
$database = $config['database'] ?? '';
$charset = $config['charset'] ?? 'utf8mb4';
$dsn = "mysql:host={$host};port={$port};dbname={$database}";
if ($charset) {
$dsn .= ";charset={$charset}";
}
return $dsn;
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'mysql';
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Drivers;
/**
* Class OracleDriver
*
* Driver for Oracle databases.
*
* @package Pairity\Database\Drivers
*/
class OracleDriver extends AbstractDriver
{
/**
* @inheritDoc
*/
protected function buildDsn(array $config): string
{
$host = $config['host'] ?? '127.0.0.1';
$port = $config['port'] ?? 1521;
$database = $config['database'] ?? '';
$serviceName = $config['service_name'] ?? $database;
$charset = $config['charset'] ?? 'AL32UTF8';
// Oracle DSN using Easy Connect syntax
$dsn = "oci:dbname=//{$host}:{$port}/{$serviceName}";
if ($charset) {
$dsn .= ";charset={$charset}";
}
return $dsn;
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'oracle';
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Drivers;
/**
* Class PostgresDriver
*
* Driver for PostgreSQL databases.
*
* @package Pairity\Database\Drivers
*/
class PostgresDriver extends AbstractDriver
{
/**
* @inheritDoc
*/
protected function buildDsn(array $config): string
{
$host = $config['host'] ?? '127.0.0.1';
$port = $config['port'] ?? 5432;
$database = $config['database'] ?? '';
$schema = $config['schema'] ?? 'public';
$sslMode = $config['sslmode'] ?? null;
$dsn = "pgsql:host={$host};port={$port};dbname={$database};options='--search_path={$schema}'";
if ($sslMode) {
$dsn .= ";sslmode={$sslMode}";
}
return $dsn;
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'pgsql';
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Drivers;
/**
* Class SQLiteDriver
*
* Driver for SQLite databases.
*
* @package Pairity\Database\Drivers
*/
class SQLiteDriver extends AbstractDriver
{
/**
* @inheritDoc
*/
protected function buildDsn(array $config): string
{
return "sqlite:" . ($config['database'] ?? ':memory:');
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'sqlite';
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Drivers;
/**
* Class SqlServerDriver
*
* Driver for SQL Server databases.
*
* @package Pairity\Database\Drivers
*/
class SqlServerDriver extends AbstractDriver
{
/**
* @inheritDoc
*/
protected function buildDsn(array $config): string
{
$host = $config['host'] ?? '127.0.0.1';
$port = $config['port'] ?? 1433;
$database = $config['database'] ?? '';
$appName = $config['appname'] ?? 'Pairity';
$dsn = "sqlsrv:Server={$host},{$port};Database={$database};APP={$appName}";
if (isset($config['encrypt'])) {
$dsn .= ";Encrypt=" . ($config['encrypt'] ? 'true' : 'false');
}
if (isset($config['trust_server_certificate'])) {
$dsn .= ";TrustServerCertificate=" . ($config['trust_server_certificate'] ? 'true' : 'false');
}
return $dsn;
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'sqlsrv';
}
}

View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Factories;
use Pairity\Contracts\Database\DatabaseManagerInterface;
use Pairity\DTO\BaseDTO;
use Pairity\DAO\BaseDAO;
/**
* Class Factory
*
* Base class for all model factories.
*/
abstract class Factory
{
/**
* @var int
*/
protected int $count = 1;
/**
* @var array
*/
protected array $states = [];
/**
* Factory constructor.
*
* @param DatabaseManagerInterface $db
*/
public function __construct(
protected DatabaseManagerInterface $db
) {
}
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
abstract public function definition(): array;
/**
* Get the model class name.
*
* @return string
*/
abstract public function model(): string;
/**
* Get the DAO class name.
*
* @return string
*/
abstract public function dao(): string;
/**
* Set the number of models to create.
*
* @param int $count
* @return $this
*/
public function count(int $count): self
{
$this->count = $count;
return $this;
}
/**
* Create models and save them to the database.
*
* @param array<string, mixed> $attributes
* @return mixed
*/
public function create(array $attributes = []): mixed
{
$results = [];
for ($i = 0; $i < $this->count; $i++) {
$data = array_merge($this->definition(), $this->applyStates(), $attributes);
$dtoClass = $this->model();
$dto = new $dtoClass($data);
$daoClass = $this->dao();
/** @var BaseDAO $dao */
$dao = $this->db->getContainer()->get($daoClass);
$dto->setDao($dao);
$dao->save($dto);
$results[] = $dto;
}
return $this->count === 1 ? $results[0] : $results;
}
/**
* Make models without saving them to the database.
*
* @param array<string, mixed> $attributes
* @return mixed
*/
public function make(array $attributes = []): mixed
{
$results = [];
for ($i = 0; $i < $this->count; $i++) {
$data = array_merge($this->definition(), $this->applyStates(), $attributes);
$dtoClass = $this->model();
$results[] = new $dtoClass($data);
}
return $this->count === 1 ? $results[0] : $results;
}
/**
* Apply the registered states.
*
* @return array<string, mixed>
*/
protected function applyStates(): array
{
$data = [];
foreach ($this->states as $state) {
if (is_callable($state)) {
$data = array_merge($data, $state($this->definition()));
} else {
$data = array_merge($data, $state);
}
}
return $data;
}
/**
* Register a new state.
*
* @param array|callable $state
* @return $this
*/
public function state(array|callable $state): self
{
$this->states[] = $state;
return $this;
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Interceptors;
use Pairity\Contracts\Database\InterceptorInterface;
/**
* Class QueryLogger
*
* Interceptor for logging queries and their execution time.
*/
class QueryLogger implements InterceptorInterface
{
/**
* @var array<array{sql: string, bindings: array, time: float, mode: string}>
*/
protected array $logs = [];
/**
* @inheritDoc
*/
public function intercept(string $query, array $bindings, string $mode, callable $next): mixed
{
$start = microtime(true);
try {
return $next($query, $bindings, $mode);
} finally {
$time = (microtime(true) - $start) * 1000; // ms
$this->logs[] = compact('query', 'bindings', 'time', 'mode');
}
}
/**
* Get all logged queries.
*
* @return array
*/
public function getLogs(): array
{
return $this->logs;
}
/**
* Clear the query logs.
*
* @return void
*/
public function clear(): void
{
$this->logs = [];
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Migrations;
use Pairity\Contracts\Database\DatabaseManagerInterface;
/**
* Class DataMigration
*
* Base class for all procedural data migrations.
*/
abstract class DataMigration
{
/**
* DataMigration constructor.
*
* @param DatabaseManagerInterface $db
*/
public function __construct(
protected DatabaseManagerInterface $db
) {
}
/**
* Execute the data migration.
*
* @return void
*/
abstract public function up(): void;
/**
* Roll back the data migration.
*
* @return void
*/
abstract public function down(): void;
}

View file

@ -1,145 +0,0 @@
<?php
namespace Pairity\Database;
use PDO;
use PDOException;
use Pairity\Contracts\ConnectionInterface;
class PdoConnection implements ConnectionInterface
{
private PDO $pdo;
/** @var array<string, \PDOStatement> */
private array $stmtCache = [];
private int $stmtCacheSize = 100; // LRU bound
/** @var null|callable */
private $queryLogger = null; // function(string $sql, array $params, float $ms): void
/** @var bool */
private bool $pretending = false;
/** @var array<int, array{sql: string, params: array<string, mixed>}> */
private array $pretendLog = [];
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
}
/** Enable/disable a bounded prepared statement cache (default size 100). */
public function setStatementCacheSize(int $size): void
{
$this->stmtCacheSize = max(0, $size);
if ($this->stmtCacheSize === 0) {
$this->stmtCache = [];
} else if (count($this->stmtCache) > $this->stmtCacheSize) {
// trim
$this->stmtCache = array_slice($this->stmtCache, -$this->stmtCacheSize, null, true);
}
}
/** Set a logger callable to receive [sql, params, ms] for each query/execute. */
public function setQueryLogger(?callable $logger): void
{
$this->queryLogger = $logger;
}
private function prepare(string $sql): \PDOStatement
{
if ($this->stmtCacheSize <= 0) {
return $this->pdo->prepare($sql);
}
if (isset($this->stmtCache[$sql])) {
// Touch for LRU by moving to end
$stmt = $this->stmtCache[$sql];
unset($this->stmtCache[$sql]);
$this->stmtCache[$sql] = $stmt;
return $stmt;
}
$stmt = $this->pdo->prepare($sql);
$this->stmtCache[$sql] = $stmt;
// Enforce LRU bound
if (count($this->stmtCache) > $this->stmtCacheSize) {
array_shift($this->stmtCache);
}
return $stmt;
}
public function query(string $sql, array $params = []): array
{
if ($this->pretending) {
$this->pretendLog[] = ['sql' => $sql, 'params' => $params];
return [];
}
$t0 = microtime(true);
$stmt = $this->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
if ($this->queryLogger) {
$ms = (microtime(true) - $t0) * 1000.0;
try { ($this->queryLogger)($sql, $params, $ms); } catch (\Throwable) {}
}
return $rows;
}
public function execute(string $sql, array $params = []): int
{
if ($this->pretending) {
$this->pretendLog[] = ['sql' => $sql, 'params' => $params];
return 0;
}
$t0 = microtime(true);
$stmt = $this->prepare($sql);
$stmt->execute($params);
$count = $stmt->rowCount();
if ($this->queryLogger) {
$ms = (microtime(true) - $t0) * 1000.0;
try { ($this->queryLogger)($sql, $params, $ms); } catch (\Throwable) {}
}
return $count;
}
public function transaction(callable $callback): mixed
{
if ($this->pretending) {
return $callback($this);
}
$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;
}
}
public function pretend(callable $callback): array
{
$this->pretending = true;
$this->pretendLog = [];
try {
$callback($this);
} finally {
$this->pretending = false;
}
return $this->pretendLog;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query;
use Pairity\DTO\BaseDTO;
use Pairity\Database\Query\Relations\Relation;
use Pairity\Exceptions\DatabaseException;
/**
* Class EagerLoader
*
* Handles batch loading of relationships to solve N+1 query problem.
*/
class EagerLoader
{
/**
* Eager load the relationships on a set of models.
*
* @param array<BaseDTO> $models
* @param array $eagerLoad
* @return array<BaseDTO>
*/
public function load(array $models, array $eagerLoad): array
{
if (empty($models) || empty($eagerLoad)) {
return $models;
}
foreach ($eagerLoad as $name => $constraints) {
// Check for nested relations
if (str_contains($name, '.')) {
$models = $this->loadNestedRelation($models, $name, $constraints);
continue;
}
$models = $this->loadRelation($models, $name, $constraints);
}
return $models;
}
/**
* Load a single relationship on a set of models.
*
* @param array<BaseDTO> $models
* @param string $name
* @param \Closure $constraints
* @return array<BaseDTO>
* @throws DatabaseException
*/
protected function loadRelation(array $models, string $name, \Closure $constraints): array
{
$relation = $this->getRelation($models, $name);
// Reset wheres because the relation constructor adds wheres for the single parent
$relation->wheres = [];
$relation->setBindings([], 'where');
// Apply constraints
$constraints($relation);
// Add eager constraints (usually WHERE IN [ids])
$relation->addEagerConstraints($models);
// Initialize the relation on all models (e.g. set to null or empty array)
$models = $relation->initRelation($models, $name);
// Get the results
$results = $relation->get();
// Match results back to parents
return $relation->match($models, $results, $name);
}
/**
* Load a nested relationship.
*
* @param array<BaseDTO> $models
* @param string $name
* @param \Closure $constraints
* @return array<BaseDTO>
*/
protected function loadNestedRelation(array $models, string $name, \Closure $constraints): array
{
$parts = explode('.', $name);
$first = array_shift($parts);
$rest = implode('.', $parts);
// Load the first level if not already loaded
$models = $this->loadRelation($models, $first, function () {});
// Gather all the related models from the first level
$results = [];
foreach ($models as $model) {
$relationValue = $model->getRelation($first);
if (is_array($relationValue)) {
$results = array_merge($results, $relationValue);
} elseif ($relationValue instanceof BaseDTO) {
$results[] = $relationValue;
}
}
// Recursively load the rest of the path on the gathered results
if (!empty($results)) {
$this->load($results, [$rest => $constraints]);
}
return $models;
}
/**
* Get the relation instance for the given models and name.
*
* @param array<BaseDTO> $models
* @param string $name
* @return Relation
* @throws DatabaseException
*/
protected function getRelation(array $models, string $name): Relation
{
$model = reset($models);
$dao = $model->getDao();
if (!method_exists($dao, $name)) {
$translator = $dao->getDb()->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class);
throw new DatabaseException(
$translator->trans('error.relation_not_found', ['relation' => $name]),
0,
null,
['relation' => $name, 'dao' => get_class($dao)]
);
}
return $dao->{$name}($model);
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query;
/**
* Class Expression
*
* Represents a raw SQL expression that should not be quoted.
*/
class Expression
{
/**
* Expression constructor.
*
* @param string $value
*/
public function __construct(
protected string $value
) {
}
/**
* Get the raw expression value.
*
* @return string
*/
public function getValue(): string
{
return $this->value;
}
/**
* Get the raw expression value.
*
* @return string
*/
public function __toString(): string
{
return $this->getValue();
}
}

View file

@ -0,0 +1,567 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query;
/**
* Class Grammar
*
* Abstract base class for SQL grammars.
*/
abstract class Grammar
{
/**
* The components that make up a select clause.
*
* @var string[]
*/
protected array $selectComponents = [
'aggregate',
'columns',
'from',
'joins',
'wheres',
'groups',
'havings',
'orders',
'limit',
'offset',
'lock',
];
/**
* Compile a select query into SQL.
*
* @param Builder $query
* @return string
*/
public function compileSelect(Builder $query): string
{
if ($query->columns === null) {
$query->columns = ['*'];
}
$sql = trim($this->concatenate($this->compileComponents($query)));
if (!empty($query->unions)) {
$sql = $this->compileUnions($query, $sql);
}
return $sql;
}
/**
* Compile an update statement into SQL.
*
* @param Builder $query
* @param array $values
* @return string
*/
public function compileUpdate(Builder $query, array $values): string
{
$table = $this->wrapTable($query->from);
$columns = [];
foreach ($values as $key => $value) {
$columns[] = $this->wrap($key) . ' = ?';
}
$columns = implode(', ', $columns);
$wheres = $this->compileWheres($query, $query->wheres);
return trim("update {$table} set {$columns} {$wheres}");
}
/**
* Compile a delete statement into SQL.
*
* @param Builder $query
* @return string
*/
public function compileDelete(Builder $query): string
{
$table = $this->wrapTable($query->from);
$wheres = $this->compileWheres($query, $query->wheres);
return trim("delete from {$table} {$wheres}");
}
/**
* Compile the components necessary for a select clause.
*
* @param Builder $query
* @return array
*/
protected function compileComponents(Builder $query): array
{
$sql = [];
foreach ($this->selectComponents as $component) {
if ($query->$component !== null) {
$method = 'compile' . ucfirst($component);
$sql[$component] = $this->$method($query, $query->$component);
}
}
return $sql;
}
/**
* Compile the "aggregate" part of a query.
*
* @param Builder $query
* @param array $aggregate
* @return string
*/
protected function compileAggregate(Builder $query, array $aggregate): string
{
$column = $this->columnize($aggregate['columns']);
if ($query->distinct && $column !== '*') {
$column = 'distinct ' . $column;
}
return 'select ' . $aggregate['function'] . '(' . $column . ') as aggregate';
}
/**
* Compile the "columns" part of a query.
*
* @param Builder $query
* @param array $columns
* @return string
*/
protected function compileColumns(Builder $query, array $columns): string
{
if ($query->aggregate !== null) {
return '';
}
$select = $query->distinct ? 'select distinct ' : 'select ';
return $select . implode(', ', array_map(function ($column) {
if (is_array($column)) {
[$query, $as] = $column;
if ($query instanceof Builder) {
return '(' . $query->toSql() . ') as ' . $this->wrap($as);
}
return $this->wrap($query) . ' as ' . $this->wrap($as);
}
return $this->wrap($column);
}, $columns));
}
/**
* Compile the "from" part of a query.
*
* @param Builder $query
* @param mixed $table
* @return string
*/
protected function compileFrom(Builder $query, $table): string
{
if (is_array($table)) {
[$subquery, $as] = $table;
return 'from (' . $subquery->toSql() . ') as ' . $this->wrapTable($as);
}
return 'from ' . $this->wrapTable($table);
}
/**
* Compile the "joins" part of a query.
*
* @param Builder $query
* @param array $joins
* @return string
*/
protected function compileJoins(Builder $query, array $joins): string
{
return implode(' ', array_map(function ($join) {
$table = $join['table'];
if (is_array($table)) {
[$subquery, $as] = $table;
$table = '(' . $subquery->toSql() . ') as ' . $this->wrapTable($as);
} else {
$table = $this->wrapTable($table);
}
return "{$join['type']} join {$table} on {$this->wrap($join['first'])} {$join['operator']} {$this->wrap($join['second'])}";
}, $joins));
}
/**
* Compile the "wheres" part of a query.
*
* @param Builder $query
* @param array $wheres
* @return string
*/
protected function compileWheres(Builder $query, array $wheres): string
{
if (empty($wheres)) {
return '';
}
$sql = [];
foreach ($wheres as $where) {
$sql[] = "{$where['boolean']} " . $this->{"compileWhere{$where['type']}"}($query, $where);
}
return 'where ' . $this->removeLeadingBoolean(implode(' ', $sql));
}
/**
* Compile a "where in" clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function compileWhereIn(Builder $query, array $where): string
{
if (empty($where['values'])) {
return '0 = 1';
}
$values = $this->parameterize($where['values']);
return "{$this->wrap($where['column'])} in ({$values})";
}
/**
* Compile a "where not in" clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function compileWhereNotIn(Builder $query, array $where): string
{
if (empty($where['values'])) {
return '1 = 1';
}
$values = $this->parameterize($where['values']);
return "{$this->wrap($where['column'])} not in ({$values})";
}
/**
* Compile a "where in subquery" clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function compileWhereInSubquery(Builder $query, array $where): string
{
return "{$this->wrap($where['column'])} in ({$where['query']->toSql()})";
}
/**
* Compile a "where not in subquery" clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function compileWhereNotInSubquery(Builder $query, array $where): string
{
return "{$this->wrap($where['column'])} not in ({$where['query']->toSql()})";
}
/**
* Compile a "where exists" clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function compileWhereExists(Builder $query, array $where): string
{
return 'exists (' . $where['query']->toSql() . ')';
}
/**
* Compile a "where not exists" clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function compileWhereNotExists(Builder $query, array $where): string
{
return 'not exists (' . $where['query']->toSql() . ')';
}
/**
* Compile a basic where clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function compileWhereBasic(Builder $query, array $where): string
{
return "{$this->wrap($where['column'])} {$where['operator']} ?";
}
/**
* Compile a "where null" clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function compileWhereNull(Builder $query, array $where): string
{
return "{$this->wrap($where['column'])} is null";
}
/**
* Compile a "where not null" clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function compileWhereNotNull(Builder $query, array $where): string
{
return "{$this->wrap($where['column'])} is not null";
}
/**
* Compile the "groups" part of a query.
*
* @param Builder $query
* @param array $groups
* @return string
*/
protected function compileGroups(Builder $query, array $groups): string
{
if (empty($groups)) {
return '';
}
return 'group by ' . $this->columnize($groups);
}
/**
* Compile the "havings" part of a query.
*
* @param Builder $query
* @param array $havings
* @return string
*/
protected function compileHavings(Builder $query, array $havings): string
{
if (empty($havings)) {
return '';
}
return 'having ' . $this->removeLeadingBoolean(implode(' ', array_map(function ($having) {
return "{$having['boolean']} {$this->wrap($having['column'])} {$having['operator']} ?";
}, $havings)));
}
/**
* Compile the "orders" part of a query.
*
* @param Builder $query
* @param array $orders
* @return string
*/
protected function compileOrders(Builder $query, array $orders): string
{
if (empty($orders)) {
return '';
}
return 'order by ' . implode(', ', array_map(function ($order) {
return "{$this->wrap($order['column'])} {$order['direction']}";
}, $orders));
}
/**
* Compile the "limit" part of a query.
*
* @param Builder $query
* @param int $limit
* @return string
*/
protected function compileLimit(Builder $query, int $limit): string
{
return 'limit ' . (int) $limit;
}
/**
* Compile the "offset" part of a query.
*
* @param Builder $query
* @param int $offset
* @return string
*/
protected function compileOffset(Builder $query, int $offset): string
{
return 'offset ' . (int) $offset;
}
/**
* Compile an upsert statement into SQL.
*
* @param Builder $query
* @param array $values
* @param array $uniqueBy
* @param array|null $update
* @return string
*/
public function compileUpsert(Builder $query, array $values, array $uniqueBy, ?array $update = null): string
{
$translator = $query->getDb()->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class);
throw new \Pairity\Exceptions\DatabaseException($translator->trans('error.upsert_not_supported'));
}
/**
* Compile the "lock" part of a query.
*
* @param Builder $query
* @param bool|string $lock
* @return string
*/
protected function compileLock(Builder $query, bool|string $lock): string
{
if (is_string($lock)) {
return $lock;
}
return $lock ? 'for update' : 'lock in share mode';
}
/**
* Create query parameter place-holders for an array.
*
* @param array $values
* @return string
*/
public function parameterize(array $values): string
{
return implode(', ', array_map([$this, 'parameter'], $values));
}
/**
* Get the appropriate query parameter place-holder for a value.
*
* @param mixed $value
* @return string
*/
public function parameter(mixed $value): string
{
return '?';
}
/**
* Wrap a table in keyword identifiers.
*
* @param string $table
* @return string
*/
public function wrapTable(string $table): string
{
return $this->wrap($table);
}
/**
* Wrap a value in keyword identifiers.
*
* @param mixed $value
* @return string
*/
public function wrap(mixed $value): string
{
if ($value instanceof Expression) {
return $value->getValue();
}
if ($value === '*') {
return $value;
}
return '"' . str_replace('"', '""', (string)$value) . '"';
}
/**
* Convert an array of column names into a delimited string.
*
* @param array $columns
* @return string
*/
public function columnize(array $columns): string
{
return implode(', ', array_map([$this, 'wrap'], $columns));
}
/**
* Concatenate an array of segments, removing empties.
*
* @param array $segments
* @return string
*/
protected function concatenate(array $segments): string
{
return implode(' ', array_filter($segments, function ($value) {
return (string) $value !== '';
}));
}
/**
* Compile the "union" queries adjoined to the main query.
*
* @param Builder $query
* @param string $sql
* @return string
*/
protected function compileUnions(Builder $query, string $sql): string
{
$unionSql = '';
foreach ($query->unions as $union) {
$unionSql .= $this->compileUnion($union);
}
return '(' . $sql . ')' . $unionSql;
}
/**
* Compile a single union statement.
*
* @param array $union
* @return string
*/
protected function compileUnion(array $union): string
{
$joiner = $union['all'] ? ' union all ' : ' union ';
return $joiner . '(' . $union['query']->toSql() . ')';
}
/**
* Remove the leading boolean from a statement.
*
* @param string $value
* @return string
*/
protected function removeLeadingBoolean(string $value): string
{
return preg_replace('/and |or /i', '', $value, 1);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Grammars;
use Pairity\Database\Query\Grammar;
/**
* Class MySqlGrammar
*/
class MySqlGrammar extends Grammar
{
/**
* Wrap a value in keyword identifiers.
*
* @param mixed $value
* @return string
*/
public function wrap(mixed $value): string
{
if ($value instanceof Expression) {
return $value->getValue();
}
if ($value === '*') {
return $value;
}
return '`' . str_replace('`', '``', (string)$value) . '`';
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Grammars;
use Pairity\Database\Query\Grammar;
/**
* Class OracleGrammar
*/
class OracleGrammar extends Grammar
{
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Grammars;
use Pairity\Database\Query\Grammar;
/**
* Class PostgresGrammar
*/
class PostgresGrammar extends Grammar
{
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Grammars;
use Pairity\Database\Query\Grammar;
/**
* Class SqlServerGrammar
*/
class SqlServerGrammar extends Grammar
{
/**
* Wrap a value in keyword identifiers.
*
* @param mixed $value
* @return string
*/
public function wrap(mixed $value): string
{
if ($value instanceof Expression) {
return $value->getValue();
}
if ($value === '*') {
return $value;
}
return '[' . str_replace(']', ']]', (string)$value) . ']';
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Grammars;
use Pairity\Database\Query\Grammar;
/**
* Class SqliteGrammar
*/
class SqliteGrammar extends Grammar
{
/**
* @inheritDoc
*/
public function compileUpsert(\Pairity\Database\Query\Builder $query, array $values, array $uniqueBy, ?array $update = null): string
{
$columns = $this->columnize(array_keys($values[0]));
$table = $this->wrapTable((string)$query->from);
$sql = "insert into {$table} ({$columns}) values ";
$parameters = array_map(function ($record) {
return '(' . $this->parameterize($record) . ')';
}, $values);
$sql .= implode(', ', $parameters);
if (is_null($update)) {
return $sql . " on conflict do nothing";
}
$sql .= " on conflict (" . $this->columnize($uniqueBy) . ") do update set ";
$columns = [];
foreach ($update as $key => $value) {
if (is_numeric($key)) {
$columns[] = $this->wrap($value) . ' = excluded.' . $this->wrap($value);
} else {
$columns[] = $this->wrap($key) . ' = ?';
}
}
return $sql . implode(', ', $columns);
}
}

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query;
/**
* Class Paginator
*
* Standardized object for paginated results.
*/
class Paginator
{
/**
* Paginator constructor.
*
* @param array $items
* @param int $total
* @param int $perPage
* @param int $currentPage
*/
public function __construct(
protected array $items,
protected int $total,
protected int $perPage,
protected int $currentPage
) {
}
/**
* Get the paginated items.
*
* @return array
*/
public function items(): array
{
return $this->items;
}
/**
* Get the total number of items.
*
* @return int
*/
public function total(): int
{
return $this->total;
}
/**
* Get the number of items per page.
*
* @return int
*/
public function perPage(): int
{
return $this->perPage;
}
/**
* Get the current page number.
*
* @return int
*/
public function currentPage(): int
{
return $this->currentPage;
}
/**
* Get the last page number.
*
* @return int
*/
public function lastPage(): int
{
return (int) ceil($this->total / $this->perPage);
}
/**
* Convert the paginator to an array.
*
* @return array
*/
public function toArray(): array
{
return [
'data' => $this->items,
'meta' => [
'total' => $this->total(),
'per_page' => $this->perPage(),
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
],
];
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query;
/**
* Class QueryResult
*
* A lightweight object wrapper for raw Query Builder results.
* Provides magic property and method access for a consistent developer experience.
*/
class QueryResult
{
/**
* QueryResult constructor.
*
* @param array<string, mixed> $attributes
*/
public function __construct(
protected array $attributes = []
) {
}
/**
* Magic property access.
*
* @param string $name
* @return mixed
*/
public function __get(string $name): mixed
{
return $this->attributes[$name] ?? null;
}
/**
* Magic method access for getters (e.g., getEmail()).
*
* @param string $name
* @param array $arguments
* @return mixed
*/
public function __call(string $name, array $arguments): mixed
{
if (str_starts_with($name, 'get')) {
$property = lcfirst(substr($name, 3));
if (array_key_exists($property, $this->attributes)) {
return $this->attributes[$property];
}
// Also try snake_case if it matches common DB conventions
$snakeProperty = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $property));
if (array_key_exists($snakeProperty, $this->attributes)) {
return $this->attributes[$snakeProperty];
}
}
return null;
}
/**
* Convert the result to a raw array.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->attributes;
}
/**
* Check if an attribute exists.
*
* @param string $name
* @return bool
*/
public function __isset(string $name): bool
{
return isset($this->attributes[$name]);
}
}

View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Relations;
use Pairity\DTO\BaseDTO;
/**
* Class BelongsTo
*
* Represents a many-to-one relationship.
*/
class BelongsTo extends Relation
{
/**
* Set the base constraints on the relation query.
*
* @return void
*/
public function addConstraints(): void
{
$this->where($this->localKey, '=', $this->parent->{$this->foreignKey});
}
/**
* Set the constraints for an eager load of the relation.
*
* @param array<BaseDTO> $models
* @return void
*/
public function addEagerConstraints(array $models): void
{
$keys = array_map(fn($model) => $model->{$this->foreignKey}, $models);
$this->whereIn($this->localKey, array_filter(array_unique($keys)));
}
/**
* Initialize the relation on a set of models.
*
* @param array $models
* @param string $relation
* @return array
*/
public function initRelation(array $models, string $relation): array
{
foreach ($models as $model) {
$model->setRelation($relation, null);
}
return $models;
}
/**
* Match the eagerly loaded results to their parents.
*
* @param array $models
* @param array $results
* @param string $relation
* @return array
*/
public function match(array $models, array $results, string $relation): array
{
$dictionary = [];
foreach ($results as $result) {
$dictionary[$result->{$this->localKey}] = $result;
}
foreach ($models as $model) {
$key = $model->{$this->foreignKey};
if (isset($dictionary[$key])) {
$model->setRelation($relation, $dictionary[$key]);
} else {
$model->setRelation($relation, null);
}
}
return $models;
}
/**
* Get the results of the relationship.
*
* @return mixed
*/
public function getResults(): mixed
{
return $this->first();
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Relations;
/**
* Class HasMany
*
* Represents a one-to-many relationship.
*/
class HasMany extends HasOneOrMany
{
/**
* Initialize the relation on a set of models.
*
* @param array $models
* @param string $relation
* @return array
*/
public function initRelation(array $models, string $relation): array
{
foreach ($models as $model) {
$model->setRelation($relation, []);
}
return $models;
}
/**
* Match the eagerly loaded results to their parents.
*
* @param array $models
* @param array $results
* @param string $relation
* @return array
*/
public function match(array $models, array $results, string $relation): array
{
return $this->matchOneOrMany($models, $results, $relation, 'many');
}
/**
* Get the results of the relationship.
*
* @return mixed
*/
public function getResults(): mixed
{
return $this->get();
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Relations;
/**
* Class HasOne
*
* Represents a one-to-one relationship.
*/
class HasOne extends HasOneOrMany
{
/**
* Initialize the relation on a set of models.
*
* @param array $models
* @param string $relation
* @return array
*/
public function initRelation(array $models, string $relation): array
{
foreach ($models as $model) {
$model->setRelation($relation, null);
}
return $models;
}
/**
* Match the eagerly loaded results to their parents.
*
* @param array $models
* @param array $results
* @param string $relation
* @return array
*/
public function match(array $models, array $results, string $relation): array
{
return $this->matchOneOrMany($models, $results, $relation, 'one');
}
/**
* Get the results of the relationship.
*
* @return mixed
*/
public function getResults(): mixed
{
return $this->first();
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Relations;
use Pairity\DTO\BaseDTO;
/**
* Class HasOneOrMany
*
* Base class for HasOne and HasMany relationships.
*/
abstract class HasOneOrMany extends Relation
{
/**
* Set the base constraints on the relation query.
*
* @return void
*/
public function addConstraints(): void
{
$this->where($this->foreignKey, '=', $this->parent->{$this->localKey});
}
/**
* Set the constraints for an eager load of the relation.
*
* @param array<BaseDTO> $models
* @return void
*/
public function addEagerConstraints(array $models): void
{
$keys = array_map(fn($model) => $model->{$this->localKey}, $models);
$this->whereIn($this->foreignKey, array_unique($keys));
}
/**
* Match the eagerly loaded results to their parents.
*
* @param array<BaseDTO> $models
* @param array<BaseDTO> $results
* @param string $relation
* @param string $type 'one' or 'many'
* @return array<BaseDTO>
*/
protected function matchOneOrMany(array $models, array $results, string $relation, string $type): array
{
$dictionary = $this->buildDictionary($results);
foreach ($models as $model) {
$key = $model->{$this->localKey};
if (isset($dictionary[$key])) {
$value = $type === 'one' ? reset($dictionary[$key]) : $dictionary[$key];
$model->setRelation($relation, $value);
} else {
$model->setRelation($relation, $type === 'one' ? null : []);
}
}
return $models;
}
/**
* Build model dictionary keyed by foreign key.
*
* @param array<BaseDTO> $results
* @return array<mixed, array<BaseDTO>>
*/
protected function buildDictionary(array $results): array
{
$dictionary = [];
foreach ($results as $result) {
$dictionary[$result->{$this->foreignKey}][] = $result;
}
return $dictionary;
}
}

View file

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Pairity\Database\Query\Relations;
use Pairity\DTO\BaseDTO;
use Pairity\DAO\BaseDAO;
use Pairity\Database\Query\Builder;
/**
* Class MorphTo
*
* Represents a polymorphic many-to-one relationship.
*/
class MorphTo extends Relation
{
/**
* The type column on the relationship.
*/
protected string $morphType;
/**
* MorphTo constructor.
*
* @param Builder $query
* @param BaseDTO $parent
* @param BaseDAO $related
* @param string $foreignKey
* @param string $localKey
* @param string $morphType
*/
public function __construct(Builder $query, BaseDTO $parent, BaseDAO $related, string $foreignKey, string $localKey, string $morphType)
{
$this->morphType = $morphType;
parent::__construct($query, $parent, $related, $foreignKey, $localKey);
}
/**
* Set the base constraints on the relation query.
*
* @return void
*/
public function addConstraints(): void
{
$this->where($this->localKey, '=', $this->parent->{$this->foreignKey})
->where($this->morphType, '=', $this->getMorphTypeForParent());
}
/**
* Set the constraints for an eager load of the relation.
*
* @param array<BaseDTO> $models
* @return void
*/
public function addEagerConstraints(array $models): void
{
$keys = array_map(fn($model) => $model->{$this->foreignKey}, $models);
$this->whereIn($this->localKey, array_filter(array_unique($keys)));
}
/**
* Initialize the relation on a set of models.
*
* @param array $models
* @param string $relation
* @return array
*/
public function initRelation(array $models, string $relation): array
{
foreach ($models as $model) {
$model->setRelation($relation, null);
}
return $models;
}
/**
* Match the eagerly loaded results to their parents.
*
* @param array $models
* @param array $results
* @param string $relation
* @return array
*/
public function match(array $models, array $results, string $relation): array
{
$dictionary = [];
foreach ($results as $result) {
$dictionary[$result->{$this->localKey}] = $result;
}
foreach ($models as $model) {
$key = $model->{$this->foreignKey};
if (isset($dictionary[$key])) {
$model->setRelation($relation, $dictionary[$key]);
} else {
$model->setRelation($relation, null);
}
}
return $models;
}
/**
* Get the results of the relationship.
*
* @return mixed
*/
public function getResults(): mixed
{
return $this->first();
}
/**
* Get the morph type for the parent model.
*
* @return string
*/
protected function getMorphTypeForParent(): string
{
return $this->parent->getDao()->getOption('morph') ?? get_class($this->parent);
}
}

Some files were not shown because too many files have changed in this diff Show more