Mongo updates
This commit is contained in:
parent
ae80d9bde1
commit
55d256506a
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
|
|
@ -25,6 +25,15 @@ jobs:
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 20
|
--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
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
@ -33,7 +42,7 @@ jobs:
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
php-version: ${{ matrix.php }}
|
||||||
extensions: pdo, pdo_mysql, pdo_sqlite
|
extensions: pdo, pdo_mysql, pdo_sqlite, mongodb
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
@ -59,5 +68,7 @@ jobs:
|
||||||
MYSQL_DB: pairity
|
MYSQL_DB: pairity
|
||||||
MYSQL_USER: root
|
MYSQL_USER: root
|
||||||
MYSQL_PASS: root
|
MYSQL_PASS: root
|
||||||
|
MONGO_HOST: 127.0.0.1
|
||||||
|
MONGO_PORT: 27017
|
||||||
run: |
|
run: |
|
||||||
vendor/bin/phpunit --colors=always
|
vendor/bin/phpunit --colors=always
|
||||||
|
|
|
||||||
47
README.md
47
README.md
|
|
@ -129,7 +129,52 @@ Notes:
|
||||||
- SQL Server
|
- SQL Server
|
||||||
- Oracle
|
- Oracle
|
||||||
|
|
||||||
NoSQL: a minimal in‑memory MongoDB stub is included (`Pairity\NoSql\Mongo\MongoConnectionInterface` and `MongoConnection`) for experimentation without external deps.
|
NoSQL:
|
||||||
|
- MongoDB (production): `Pairity\NoSql\Mongo\MongoClientConnection` via `mongodb/mongodb` + `ext-mongodb`.
|
||||||
|
- MongoDB (stub): `Pairity\NoSql\Mongo\MongoConnection` (in‑memory) remains for experimentation without external deps.
|
||||||
|
|
||||||
|
### MongoDB (production adapter)
|
||||||
|
|
||||||
|
Pairity includes a production‑ready MongoDB adapter that wraps the official `mongodb/mongodb` library.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- PHP `ext-mongodb` (installed in PHP), and Composer dependency `mongodb/mongodb` (already required by this package).
|
||||||
|
|
||||||
|
Connect using the `MongoConnectionManager`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Pairity\NoSql\Mongo\MongoConnectionManager;
|
||||||
|
|
||||||
|
// Option A: Full URI
|
||||||
|
$conn = MongoConnectionManager::make([
|
||||||
|
'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Option B: Discrete params
|
||||||
|
$conn = MongoConnectionManager::make([
|
||||||
|
'host' => '127.0.0.1',
|
||||||
|
'port' => 27017,
|
||||||
|
// 'username' => 'user',
|
||||||
|
// 'password' => 'pass',
|
||||||
|
// 'authSource' => 'admin',
|
||||||
|
// 'replicaSet' => 'rs0',
|
||||||
|
// 'tls' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Basic CRUD
|
||||||
|
$db = 'app';
|
||||||
|
$col = 'users';
|
||||||
|
|
||||||
|
$id = $conn->insertOne($db, $col, ['email' => 'mongo@example.com', 'name' => 'Alice']);
|
||||||
|
$one = $conn->find($db, $col, ['_id' => $id]);
|
||||||
|
$conn->updateOne($db, $col, ['_id' => $id], ['$set' => ['name' => 'Alice Updated']]);
|
||||||
|
$conn->deleteOne($db, $col, ['_id' => $id]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `_id` strings that look like 24‑hex ObjectIds are automatically converted to `ObjectId` on input; returned documents convert `ObjectId` back to strings.
|
||||||
|
- Aggregation pipelines are supported via `$conn->aggregate($db, $collection, $pipeline, $options)`.
|
||||||
|
- See `examples/nosql/mongo_crud.php` for a runnable demo.
|
||||||
|
|
||||||
## Raw SQL
|
## Raw SQL
|
||||||
|
|
||||||
|
|
|
||||||
48
bin/pairity
48
bin/pairity
|
|
@ -133,6 +133,9 @@ Usage:
|
||||||
pairity status [--path=DIR] [--config=FILE]
|
pairity status [--path=DIR] [--config=FILE]
|
||||||
pairity reset [--config=FILE]
|
pairity reset [--config=FILE]
|
||||||
pairity make:migration Name [--path=DIR]
|
pairity make:migration Name [--path=DIR]
|
||||||
|
pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique]
|
||||||
|
pairity mongo:index:drop DB COLLECTION NAME
|
||||||
|
pairity mongo:index:list DB COLLECTION
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
DB_DRIVER, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PATH (for sqlite)
|
DB_DRIVER, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PATH (for sqlite)
|
||||||
|
|
@ -243,6 +246,51 @@ PHP;
|
||||||
stdout('Created: ' . $file);
|
stdout('Created: ' . $file);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'mongo:index:ensure':
|
||||||
|
// Args: DB COLLECTION KEYS_JSON [--unique]
|
||||||
|
$db = $args[0] ?? null;
|
||||||
|
$col = $args[1] ?? null;
|
||||||
|
$keysJson = $args[2] ?? null;
|
||||||
|
if (!$db || !$col || !$keysJson) {
|
||||||
|
stderr('Usage: pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique]');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$config = loadConfig($args);
|
||||||
|
$conn = \Pairity\NoSql\Mongo\MongoConnectionManager::make($config);
|
||||||
|
$idx = new \Pairity\NoSql\Mongo\IndexManager($conn, $db, $col);
|
||||||
|
$keys = json_decode($keysJson, true);
|
||||||
|
if (!is_array($keys)) { stderr('Invalid KEYS_JSON (must be object like {"email":1})'); exit(1); }
|
||||||
|
$opts = [];
|
||||||
|
if (!empty($args['unique'])) { $opts['unique'] = true; }
|
||||||
|
$name = $idx->ensureIndex($keys, $opts);
|
||||||
|
stdout('Ensured index: ' . $name);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'mongo:index:drop':
|
||||||
|
// Args: DB COLLECTION NAME
|
||||||
|
$db = $args[0] ?? null;
|
||||||
|
$col = $args[1] ?? null;
|
||||||
|
$name = $args[2] ?? null;
|
||||||
|
if (!$db || !$col || !$name) { stderr('Usage: pairity mongo:index:drop DB COLLECTION NAME'); exit(1); }
|
||||||
|
$config = loadConfig($args);
|
||||||
|
$conn = \Pairity\NoSql\Mongo\MongoConnectionManager::make($config);
|
||||||
|
$idx = new \Pairity\NoSql\Mongo\IndexManager($conn, $db, $col);
|
||||||
|
$idx->dropIndex($name);
|
||||||
|
stdout('Dropped index: ' . $name);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'mongo:index:list':
|
||||||
|
// Args: DB COLLECTION
|
||||||
|
$db = $args[0] ?? null;
|
||||||
|
$col = $args[1] ?? null;
|
||||||
|
if (!$db || !$col) { stderr('Usage: pairity mongo:index:list DB COLLECTION'); exit(1); }
|
||||||
|
$config = loadConfig($args);
|
||||||
|
$conn = \Pairity\NoSql\Mongo\MongoConnectionManager::make($config);
|
||||||
|
$idx = new \Pairity\NoSql\Mongo\IndexManager($conn, $db, $col);
|
||||||
|
$list = $idx->listIndexes();
|
||||||
|
stdout(json_encode($list));
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
cmd_help();
|
cmd_help();
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
{ "name": "Pairity Contributors" }
|
{ "name": "Pairity Contributors" }
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.1"
|
"php": ">=8.1",
|
||||||
|
"ext-mongodb": "*",
|
||||||
|
"mongodb/mongodb": "^1.19"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|
|
||||||
50
examples/nosql/mongo_crud.php
Normal file
50
examples/nosql/mongo_crud.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?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";
|
||||||
62
examples/nosql/mongo_dao_crud.php
Normal file
62
examples/nosql/mongo_dao_crud.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?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";
|
||||||
|
}
|
||||||
75
examples/nosql/mongo_relations_demo.php
Normal file
75
examples/nosql/mongo_relations_demo.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?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";
|
||||||
|
}
|
||||||
386
src/NoSql/Mongo/AbstractMongoDao.php
Normal file
386
src/NoSql/Mongo/AbstractMongoDao.php
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pairity\NoSql\Mongo;
|
||||||
|
|
||||||
|
use Pairity\Model\AbstractDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base DAO for MongoDB collections returning DTOs.
|
||||||
|
*
|
||||||
|
* Usage: extend and implement collection() + dtoClass().
|
||||||
|
*/
|
||||||
|
abstract class AbstractMongoDao
|
||||||
|
{
|
||||||
|
protected MongoConnectionInterface $connection;
|
||||||
|
|
||||||
|
/** @var array<int,string>|null */
|
||||||
|
private ?array $projection = null; // list of field names to include
|
||||||
|
/** @var array<string,int> */
|
||||||
|
private array $sortSpec = [];
|
||||||
|
private ?int $limitVal = null;
|
||||||
|
private ?int $skipVal = null;
|
||||||
|
|
||||||
|
/** @var array<int,string> */
|
||||||
|
private array $with = [];
|
||||||
|
/** @var array<string, array<int,string>> */
|
||||||
|
private array $relationFields = [];
|
||||||
|
|
||||||
|
public function __construct(MongoConnectionInterface $connection)
|
||||||
|
{
|
||||||
|
$this->connection = $connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collection name (e.g., "users"). */
|
||||||
|
abstract protected function collection(): string;
|
||||||
|
|
||||||
|
/** @return class-string<AbstractDto> */
|
||||||
|
abstract protected function dtoClass(): string;
|
||||||
|
|
||||||
|
/** Access to underlying connection. */
|
||||||
|
public function getConnection(): MongoConnectionInterface
|
||||||
|
{
|
||||||
|
return $this->connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relation metadata (MVP). Override in concrete DAO. */
|
||||||
|
protected function relations(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= Query modifiers =========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specify projection fields to include on base entity and optionally on relations via dot-notation.
|
||||||
|
* Example: fields('email','name','posts.title')
|
||||||
|
*/
|
||||||
|
public function fields(string ...$fields): static
|
||||||
|
{
|
||||||
|
$base = [];
|
||||||
|
foreach ($fields as $f) {
|
||||||
|
$f = (string)$f;
|
||||||
|
if ($f === '') continue;
|
||||||
|
if (str_contains($f, '.')) {
|
||||||
|
[$rel, $col] = explode('.', $f, 2);
|
||||||
|
if ($rel !== '' && $col !== '') {
|
||||||
|
$this->relationFields[$rel][] = $col;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$base[] = $f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->projection = $base ?: null;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sorting spec, e.g., sort(['created_at' => -1]) */
|
||||||
|
public function sort(array $spec): static
|
||||||
|
{
|
||||||
|
// sanitize values to 1 or -1
|
||||||
|
$out = [];
|
||||||
|
foreach ($spec as $k => $v) {
|
||||||
|
$out[(string)$k] = ((int)$v) < 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
$this->sortSpec = $out;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function limit(int $n): static
|
||||||
|
{
|
||||||
|
$this->limitVal = max(0, $n);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function skip(int $n): static
|
||||||
|
{
|
||||||
|
$this->skipVal = max(0, $n);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= CRUD =========
|
||||||
|
|
||||||
|
/** @param array<string,mixed>|Filter $filter */
|
||||||
|
public function findOneBy(array|Filter $filter): ?AbstractDto
|
||||||
|
{
|
||||||
|
$opts = $this->buildOptions();
|
||||||
|
$opts['limit'] = 1;
|
||||||
|
$docs = $this->connection->find($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $opts);
|
||||||
|
$this->resetModifiers();
|
||||||
|
$row = $docs[0] ?? null;
|
||||||
|
return $row ? $this->hydrate($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed>|Filter $filter
|
||||||
|
* @param array<string,mixed> $options Additional options (merged after internal modifiers)
|
||||||
|
* @return array<int,AbstractDto>
|
||||||
|
*/
|
||||||
|
public function findAllBy(array|Filter $filter = [], array $options = []): array
|
||||||
|
{
|
||||||
|
$opts = $this->buildOptions();
|
||||||
|
// external override/merge
|
||||||
|
foreach ($options as $k => $v) { $opts[$k] = $v; }
|
||||||
|
$docs = $this->connection->find($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $opts);
|
||||||
|
$dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
|
||||||
|
if ($dtos && $this->with) {
|
||||||
|
$this->attachRelations($dtos);
|
||||||
|
}
|
||||||
|
$this->resetModifiers();
|
||||||
|
return $dtos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(string $id): ?AbstractDto
|
||||||
|
{
|
||||||
|
return $this->findOneBy(['_id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $data */
|
||||||
|
public function insert(array $data): AbstractDto
|
||||||
|
{
|
||||||
|
$id = $this->connection->insertOne($this->databaseName(), $this->collection(), $data);
|
||||||
|
// fetch back
|
||||||
|
return $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $data */
|
||||||
|
public function update(string $id, array $data): AbstractDto
|
||||||
|
{
|
||||||
|
$this->connection->updateOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $data]);
|
||||||
|
return $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteById(string $id): int
|
||||||
|
{
|
||||||
|
return $this->connection->deleteOne($this->databaseName(), $this->collection(), ['_id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed>|Filter $filter */
|
||||||
|
public function deleteBy(array|Filter $filter): int
|
||||||
|
{
|
||||||
|
// For MVP provide deleteOne semantic; bulk deletes could be added later
|
||||||
|
return $this->connection->deleteOne($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert by id convenience. */
|
||||||
|
public function upsertById(string $id, array $data): string
|
||||||
|
{
|
||||||
|
return $this->connection->upsertOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed>|Filter $filter @param array<string,mixed> $update */
|
||||||
|
public function upsertBy(array|Filter $filter, array $update): string
|
||||||
|
{
|
||||||
|
return $this->connection->upsertOne($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch related docs where a field is within the given set of values.
|
||||||
|
* @param string $field
|
||||||
|
* @param array<int,string> $values
|
||||||
|
* @return array<int,AbstractDto>
|
||||||
|
*/
|
||||||
|
public function findAllWhereIn(string $field, array $values): array
|
||||||
|
{
|
||||||
|
if (!$values) return [];
|
||||||
|
// Normalize values (unique)
|
||||||
|
$values = array_values(array_unique($values));
|
||||||
|
$opts = $this->buildOptions();
|
||||||
|
$docs = $this->connection->find($this->databaseName(), $this->collection(), [ $field => ['$in' => $values] ], $opts);
|
||||||
|
return array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= Dynamic helpers =========
|
||||||
|
|
||||||
|
public function __call(string $name, array $arguments): mixed
|
||||||
|
{
|
||||||
|
if (preg_match('/^(findOneBy|findAllBy|updateBy|deleteBy)([A-Z][A-Za-z0-9_]*)$/', $name, $m)) {
|
||||||
|
$op = $m[1];
|
||||||
|
$col = $this->normalizeColumn($m[2]);
|
||||||
|
switch ($op) {
|
||||||
|
case 'findOneBy':
|
||||||
|
return $this->findOneBy([$col => $arguments[0] ?? null]);
|
||||||
|
case 'findAllBy':
|
||||||
|
return $this->findAllBy([$col => $arguments[0] ?? null]);
|
||||||
|
case 'updateBy':
|
||||||
|
$value = $arguments[0] ?? null;
|
||||||
|
$data = $arguments[1] ?? [];
|
||||||
|
if (!is_array($data)) {
|
||||||
|
throw new \InvalidArgumentException('updateBy* expects second argument as array $data');
|
||||||
|
}
|
||||||
|
$one = $this->findOneBy([$col => $value]);
|
||||||
|
if (!$one) { return 0; }
|
||||||
|
$id = (string)($one->toArray(false)['_id'] ?? '');
|
||||||
|
$this->update($id, $data);
|
||||||
|
return 1;
|
||||||
|
case 'deleteBy':
|
||||||
|
return $this->deleteBy([$col => $arguments[0] ?? null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new \BadMethodCallException(static::class . "::{$name} does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= Internals =========
|
||||||
|
|
||||||
|
protected function normalizeColumn(string $studly): string
|
||||||
|
{
|
||||||
|
$snake = preg_replace('/(?<!^)[A-Z]/', '_$0', $studly) ?? $studly;
|
||||||
|
return strtolower($snake);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function hydrate(array $doc): AbstractDto
|
||||||
|
{
|
||||||
|
// Ensure _id is a string for DTO friendliness
|
||||||
|
if (isset($doc['_id']) && !is_string($doc['_id'])) {
|
||||||
|
$doc['_id'] = (string)$doc['_id'];
|
||||||
|
}
|
||||||
|
$class = $this->dtoClass();
|
||||||
|
/** @var AbstractDto $dto */
|
||||||
|
$dto = $class::fromArray($doc);
|
||||||
|
return $dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed>|Filter $filter */
|
||||||
|
private function normalizeFilterInput(array|Filter $filter): array
|
||||||
|
{
|
||||||
|
if ($filter instanceof Filter) {
|
||||||
|
return $filter->toArray();
|
||||||
|
}
|
||||||
|
return $filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build MongoDB driver options from current modifiers. */
|
||||||
|
private function buildOptions(): array
|
||||||
|
{
|
||||||
|
$opts = [];
|
||||||
|
if ($this->projection) {
|
||||||
|
$proj = [];
|
||||||
|
foreach ($this->projection as $f) { $proj[$f] = 1; }
|
||||||
|
$opts['projection'] = $proj;
|
||||||
|
}
|
||||||
|
if ($this->sortSpec) { $opts['sort'] = $this->sortSpec; }
|
||||||
|
if ($this->limitVal !== null) { $opts['limit'] = $this->limitVal; }
|
||||||
|
if ($this->skipVal !== null) { $opts['skip'] = $this->skipVal; }
|
||||||
|
return $opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetModifiers(): void
|
||||||
|
{
|
||||||
|
$this->projection = null;
|
||||||
|
$this->sortSpec = [];
|
||||||
|
$this->limitVal = null;
|
||||||
|
$this->skipVal = null;
|
||||||
|
$this->with = [];
|
||||||
|
$this->relationFields = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve database name from collection string if provided as db.collection; else default to 'app'. */
|
||||||
|
private function databaseName(): string
|
||||||
|
{
|
||||||
|
// Allow subclasses to define "db.collection" in collection() if they want to target a specific DB quickly
|
||||||
|
$col = $this->collection();
|
||||||
|
if (str_contains($col, '.')) {
|
||||||
|
return explode('.', $col, 2)[0];
|
||||||
|
}
|
||||||
|
return 'app';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Relations (MVP) =====
|
||||||
|
|
||||||
|
/** Eager load relations on next find* call. */
|
||||||
|
public function with(array $relations): static
|
||||||
|
{
|
||||||
|
$this->with = $relations;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazy load a single relation for one DTO. */
|
||||||
|
public function load(AbstractDto $dto, string $relation): void
|
||||||
|
{
|
||||||
|
$this->with([$relation]);
|
||||||
|
$this->attachRelations([$dto]);
|
||||||
|
// do not call resetModifiers here to avoid wiping user sort/limit; with() is cleared in attachRelations
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int,AbstractDto> $dtos */
|
||||||
|
public function loadMany(array $dtos, string $relation): void
|
||||||
|
{
|
||||||
|
if (!$dtos) return;
|
||||||
|
$this->with([$relation]);
|
||||||
|
$this->attachRelations($dtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int,AbstractDto> $parents */
|
||||||
|
protected function attachRelations(array $parents): void
|
||||||
|
{
|
||||||
|
if (!$parents) return;
|
||||||
|
$relations = $this->relations();
|
||||||
|
foreach ($this->with as $name) {
|
||||||
|
if (!isset($relations[$name])) continue;
|
||||||
|
$cfg = $relations[$name];
|
||||||
|
$type = (string)($cfg['type'] ?? '');
|
||||||
|
$daoClass = $cfg['dao'] ?? null;
|
||||||
|
if (!is_string($daoClass) || $type === '') continue;
|
||||||
|
|
||||||
|
/** @var class-string<\Pairity\NoSql\Mongo\AbstractMongoDao> $daoClass */
|
||||||
|
$related = new $daoClass($this->connection);
|
||||||
|
$relFields = $this->relationFields[$name] ?? null;
|
||||||
|
if ($relFields) { $related->fields(...$relFields); }
|
||||||
|
|
||||||
|
if ($type === 'hasMany' || $type === 'hasOne') {
|
||||||
|
$foreignKey = (string)($cfg['foreignKey'] ?? ''); // on child
|
||||||
|
$localKey = (string)($cfg['localKey'] ?? '_id'); // on parent
|
||||||
|
if ($foreignKey === '') continue;
|
||||||
|
|
||||||
|
$keys = [];
|
||||||
|
foreach ($parents as $p) {
|
||||||
|
$arr = $p->toArray(false);
|
||||||
|
if (isset($arr[$localKey])) { $keys[] = (string)$arr[$localKey]; }
|
||||||
|
}
|
||||||
|
if (!$keys) continue;
|
||||||
|
|
||||||
|
$children = $related->findAllWhereIn($foreignKey, $keys);
|
||||||
|
$grouped = [];
|
||||||
|
foreach ($children as $child) {
|
||||||
|
$fk = $child->toArray(false)[$foreignKey] ?? null;
|
||||||
|
if ($fk !== null) { $grouped[(string)$fk][] = $child; }
|
||||||
|
}
|
||||||
|
foreach ($parents as $p) {
|
||||||
|
$arr = $p->toArray(false);
|
||||||
|
$key = isset($arr[$localKey]) ? (string)$arr[$localKey] : null;
|
||||||
|
$list = ($key !== null && isset($grouped[$key])) ? $grouped[$key] : [];
|
||||||
|
if ($type === 'hasOne') {
|
||||||
|
$p->setRelation($name, $list[0] ?? null);
|
||||||
|
} else {
|
||||||
|
$p->setRelation($name, $list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($type === 'belongsTo') {
|
||||||
|
$foreignKey = (string)($cfg['foreignKey'] ?? ''); // on parent
|
||||||
|
$otherKey = (string)($cfg['otherKey'] ?? '_id'); // on related
|
||||||
|
if ($foreignKey === '') continue;
|
||||||
|
|
||||||
|
$ownerIds = [];
|
||||||
|
foreach ($parents as $p) {
|
||||||
|
$arr = $p->toArray(false);
|
||||||
|
if (isset($arr[$foreignKey])) { $ownerIds[] = (string)$arr[$foreignKey]; }
|
||||||
|
}
|
||||||
|
if (!$ownerIds) continue;
|
||||||
|
|
||||||
|
$owners = $related->findAllWhereIn($otherKey, $ownerIds);
|
||||||
|
$byId = [];
|
||||||
|
foreach ($owners as $o) {
|
||||||
|
$id = $o->toArray(false)[$otherKey] ?? null;
|
||||||
|
if ($id !== null) { $byId[(string)$id] = $o; }
|
||||||
|
}
|
||||||
|
foreach ($parents as $p) {
|
||||||
|
$arr = $p->toArray(false);
|
||||||
|
$fk = isset($arr[$foreignKey]) ? (string)$arr[$foreignKey] : null;
|
||||||
|
$p->setRelation($name, ($fk !== null && isset($byId[$fk])) ? $byId[$fk] : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// reset eager-load request
|
||||||
|
$this->with = [];
|
||||||
|
// keep relationFields for potential subsequent relation loads within same high-level call
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/NoSql/Mongo/Filter.php
Normal file
90
src/NoSql/Mongo/Filter.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pairity\NoSql\Mongo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal fluent builder for MongoDB filters.
|
||||||
|
*/
|
||||||
|
final class Filter
|
||||||
|
{
|
||||||
|
/** @var array<string,mixed> */
|
||||||
|
private array $query = [];
|
||||||
|
|
||||||
|
private function __construct(array $initial = [])
|
||||||
|
{
|
||||||
|
$this->query = $initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function make(): self
|
||||||
|
{
|
||||||
|
return new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed> */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return $this->query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function whereEq(string $field, mixed $value): self
|
||||||
|
{
|
||||||
|
$this->query[$field] = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int,mixed> $values */
|
||||||
|
public function whereIn(string $field, array $values): self
|
||||||
|
{
|
||||||
|
$this->query[$field] = ['$in' => array_values($values)];
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function gt(string $field, mixed $value): self
|
||||||
|
{
|
||||||
|
$this->op($field, '$gt', $value);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function gte(string $field, mixed $value): self
|
||||||
|
{
|
||||||
|
$this->op($field, '$gte', $value);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lt(string $field, mixed $value): self
|
||||||
|
{
|
||||||
|
$this->op($field, '$lt', $value);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lte(string $field, mixed $value): self
|
||||||
|
{
|
||||||
|
$this->op($field, '$lte', $value);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an $or clause with an array of filters (arrays or Filter instances). */
|
||||||
|
public function orWhere(array $conditions): self
|
||||||
|
{
|
||||||
|
$ors = [];
|
||||||
|
foreach ($conditions as $c) {
|
||||||
|
if ($c instanceof self) {
|
||||||
|
$ors[] = $c->toArray();
|
||||||
|
} elseif (is_array($c)) {
|
||||||
|
$ors[] = $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($ors)) {
|
||||||
|
$this->query['$or'] = $ors;
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function op(string $field, string $op, mixed $value): void
|
||||||
|
{
|
||||||
|
$cur = $this->query[$field] ?? [];
|
||||||
|
if (!is_array($cur)) { $cur = []; }
|
||||||
|
$cur[$op] = $value;
|
||||||
|
$this->query[$field] = $cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/NoSql/Mongo/IndexManager.php
Normal file
68
src/NoSql/Mongo/IndexManager.php
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pairity\NoSql\Mongo;
|
||||||
|
|
||||||
|
use MongoDB\Client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple Index manager for MongoDB collections.
|
||||||
|
*/
|
||||||
|
final class IndexManager
|
||||||
|
{
|
||||||
|
private MongoConnectionInterface $connection;
|
||||||
|
private string $database;
|
||||||
|
private string $collection;
|
||||||
|
|
||||||
|
public function __construct(MongoConnectionInterface $connection, string $database, string $collection)
|
||||||
|
{
|
||||||
|
$this->connection = $connection;
|
||||||
|
$this->database = $database;
|
||||||
|
$this->collection = $collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure index on keys (e.g., ['email' => 1]) with options (e.g., ['unique' => true]).
|
||||||
|
* Returns index name.
|
||||||
|
* @param array<string,int> $keys
|
||||||
|
* @param array<string,mixed> $options
|
||||||
|
*/
|
||||||
|
public function ensureIndex(array $keys, array $options = []): string
|
||||||
|
{
|
||||||
|
$client = $this->getClient();
|
||||||
|
$mgr = $client->selectCollection($this->database, $this->collection)->createIndex($keys, $options);
|
||||||
|
return (string)$mgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop an index by name. */
|
||||||
|
public function dropIndex(string $name): void
|
||||||
|
{
|
||||||
|
$client = $this->getClient();
|
||||||
|
$client->selectCollection($this->database, $this->collection)->dropIndex($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array<string,mixed>> */
|
||||||
|
public function listIndexes(): array
|
||||||
|
{
|
||||||
|
$client = $this->getClient();
|
||||||
|
$it = $client->selectCollection($this->database, $this->collection)->listIndexes();
|
||||||
|
$out = [];
|
||||||
|
foreach ($it as $ix) {
|
||||||
|
$out[] = json_decode(json_encode($ix), true) ?? [];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getClient(): Client
|
||||||
|
{
|
||||||
|
if ($this->connection instanceof MongoClientConnection) {
|
||||||
|
return $this->connection->getClient();
|
||||||
|
}
|
||||||
|
// Fallback: attempt to reflect getClient()
|
||||||
|
if (method_exists($this->connection, 'getClient')) {
|
||||||
|
/** @var Client $c */
|
||||||
|
$c = $this->connection->getClient();
|
||||||
|
return $c;
|
||||||
|
}
|
||||||
|
throw new \RuntimeException('IndexManager requires MongoClientConnection');
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/NoSql/Mongo/MongoClientConnection.php
Normal file
215
src/NoSql/Mongo/MongoClientConnection.php
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pairity\NoSql\Mongo;
|
||||||
|
|
||||||
|
use MongoDB\Client;
|
||||||
|
use MongoDB\BSON\ObjectId;
|
||||||
|
use MongoDB\Driver\Session;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production MongoDB adapter wrapping mongodb/mongodb Client.
|
||||||
|
* Implements the existing MongoConnectionInterface methods.
|
||||||
|
*/
|
||||||
|
class MongoClientConnection implements MongoConnectionInterface
|
||||||
|
{
|
||||||
|
private Client $client;
|
||||||
|
|
||||||
|
public function __construct(Client $client)
|
||||||
|
{
|
||||||
|
$this->client = $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClient(): Client
|
||||||
|
{
|
||||||
|
return $this->client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(string $database, string $collection, array $filter = [], array $options = []): iterable
|
||||||
|
{
|
||||||
|
$coll = $this->client->selectCollection($database, $collection);
|
||||||
|
$cursor = $coll->find($this->normalizeFilter($filter), $options);
|
||||||
|
$out = [];
|
||||||
|
foreach ($cursor as $doc) {
|
||||||
|
$out[] = $this->docToArray($doc);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insertOne(string $database, string $collection, array $document): string
|
||||||
|
{
|
||||||
|
$coll = $this->client->selectCollection($database, $collection);
|
||||||
|
$result = $coll->insertOne($this->normalizeDocument($document));
|
||||||
|
$id = $result->getInsertedId();
|
||||||
|
return (string)$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int
|
||||||
|
{
|
||||||
|
$coll = $this->client->selectCollection($database, $collection);
|
||||||
|
$res = $coll->updateOne($this->normalizeFilter($filter), $update, $options);
|
||||||
|
return $res->getModifiedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteOne(string $database, string $collection, array $filter, array $options = []): int
|
||||||
|
{
|
||||||
|
$coll = $this->client->selectCollection($database, $collection);
|
||||||
|
$res = $coll->deleteOne($this->normalizeFilter($filter), $options);
|
||||||
|
return $res->getDeletedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable
|
||||||
|
{
|
||||||
|
$coll = $this->client->selectCollection($database, $collection);
|
||||||
|
$cursor = $coll->aggregate($pipeline, $options);
|
||||||
|
$out = [];
|
||||||
|
foreach ($cursor as $doc) {
|
||||||
|
$out[] = $this->docToArray($doc);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upsertOne(string $database, string $collection, array $filter, array $update): string
|
||||||
|
{
|
||||||
|
$coll = $this->client->selectCollection($database, $collection);
|
||||||
|
// Normalize _id in filter (supports $in handled by normalizeFilter)
|
||||||
|
$filter = $this->normalizeFilter($filter);
|
||||||
|
$res = $coll->updateOne($filter, $update, ['upsert' => true]);
|
||||||
|
$up = $res->getUpsertedId();
|
||||||
|
if ($up !== null) {
|
||||||
|
return (string)$up;
|
||||||
|
}
|
||||||
|
// Not an upsert (matched existing). Best-effort: fetch one doc and return its _id as string.
|
||||||
|
$doc = $coll->findOne($filter);
|
||||||
|
if ($doc) {
|
||||||
|
$arr = $this->docToArray($doc);
|
||||||
|
return isset($arr['_id']) ? (string)$arr['_id'] : '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withSession(callable $callback): mixed
|
||||||
|
{
|
||||||
|
/** @var Session $session */
|
||||||
|
$session = $this->client->startSession();
|
||||||
|
try {
|
||||||
|
return $callback($this, $session);
|
||||||
|
} finally {
|
||||||
|
try { $session->endSession(); } catch (\Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withTransaction(callable $callback): mixed
|
||||||
|
{
|
||||||
|
/** @var Session $session */
|
||||||
|
$session = $this->client->startSession();
|
||||||
|
try {
|
||||||
|
$result = $session->startTransaction();
|
||||||
|
$ret = $callback($this, $session);
|
||||||
|
$session->commitTransaction();
|
||||||
|
return $ret;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
try { $session->abortTransaction(); } catch (\Throwable) {}
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
try { $session->endSession(); } catch (\Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $filter */
|
||||||
|
private function normalizeFilter(array $filter): array
|
||||||
|
{
|
||||||
|
// Recursively walk the filter and convert any _id string(s) that look like 24-hex to ObjectId
|
||||||
|
$walker = function (&$node, $key = null) use (&$walker) {
|
||||||
|
if (is_array($node)) {
|
||||||
|
foreach ($node as $k => &$v) {
|
||||||
|
$walker($v, $k);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($key === '_id' && is_string($node) && preg_match('/^[a-f\d]{24}$/i', $node)) {
|
||||||
|
try { $node = new ObjectId($node); } catch (\Throwable) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$convertIdContainer = function (&$value) use (&$convertIdContainer) {
|
||||||
|
// Handle structures like ['_id' => ['$in' => ['...','...']]]
|
||||||
|
if (is_string($value) && preg_match('/^[a-f\d]{24}$/i', $value)) {
|
||||||
|
try { $value = new ObjectId($value); } catch (\Throwable) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (is_array($value)) {
|
||||||
|
foreach ($value as $k => &$v) {
|
||||||
|
$convertIdContainer($v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top-level traversal
|
||||||
|
foreach ($filter as $k => &$v) {
|
||||||
|
if ($k === '_id') {
|
||||||
|
$convertIdContainer($v);
|
||||||
|
} elseif (is_array($v)) {
|
||||||
|
// Recurse into nested boolean operators ($and/$or) etc.
|
||||||
|
foreach ($v as $kk => &$vv) {
|
||||||
|
if ($kk === '_id') {
|
||||||
|
$convertIdContainer($vv);
|
||||||
|
} elseif (is_array($vv)) {
|
||||||
|
foreach ($vv as $kkk => &$vvv) {
|
||||||
|
if ($kkk === '_id') {
|
||||||
|
$convertIdContainer($vvv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($v);
|
||||||
|
|
||||||
|
return $filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $doc */
|
||||||
|
private function normalizeDocument(array $doc): array
|
||||||
|
{
|
||||||
|
if (isset($doc['_id']) && is_string($doc['_id']) && preg_match('/^[a-f\d]{24}$/i', $doc['_id'])) {
|
||||||
|
try { $doc['_id'] = new ObjectId($doc['_id']); } catch (\Throwable) {}
|
||||||
|
}
|
||||||
|
return $doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert BSON document or array to a plain associative array, including ObjectId cast to string.
|
||||||
|
*/
|
||||||
|
private function docToArray(mixed $doc): array
|
||||||
|
{
|
||||||
|
if ($doc instanceof \MongoDB\Model\BSONDocument) {
|
||||||
|
$doc = $doc->getArrayCopy();
|
||||||
|
} elseif ($doc instanceof \ArrayObject) {
|
||||||
|
$doc = $doc->getArrayCopy();
|
||||||
|
}
|
||||||
|
if (!is_array($doc)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($doc as $k => $v) {
|
||||||
|
if ($v instanceof ObjectId) {
|
||||||
|
$out[$k] = (string)$v;
|
||||||
|
} elseif ($v instanceof \MongoDB\BSON\UTCDateTime) {
|
||||||
|
$out[$k] = $v->toDateTime()->format('c');
|
||||||
|
} elseif ($v instanceof \MongoDB\Model\BSONDocument || $v instanceof \ArrayObject) {
|
||||||
|
$out[$k] = $this->docToArray($v);
|
||||||
|
} elseif (is_array($v)) {
|
||||||
|
$out[$k] = array_map(function ($item) {
|
||||||
|
if ($item instanceof ObjectId) return (string)$item;
|
||||||
|
if ($item instanceof \MongoDB\Model\BSONDocument || $item instanceof \ArrayObject) {
|
||||||
|
return $this->docToArray($item);
|
||||||
|
}
|
||||||
|
return $item;
|
||||||
|
}, $v);
|
||||||
|
} else {
|
||||||
|
$out[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,4 +18,13 @@ interface MongoConnectionInterface
|
||||||
|
|
||||||
/** @param array<int, array<string,mixed>> $pipeline */
|
/** @param array<int, array<string,mixed>> $pipeline */
|
||||||
public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable;
|
public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable;
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $filter @param array<string,mixed> $update */
|
||||||
|
public function upsertOne(string $database, string $collection, array $filter, array $update): string;
|
||||||
|
|
||||||
|
/** Execute a callback with a client session; callback receives the connection instance and session as args. */
|
||||||
|
public function withSession(callable $callback): mixed;
|
||||||
|
|
||||||
|
/** Execute a callback wrapped in a driver transaction when supported. */
|
||||||
|
public function withTransaction(callable $callback): mixed;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
src/NoSql/Mongo/MongoConnectionManager.php
Normal file
58
src/NoSql/Mongo/MongoConnectionManager.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pairity\NoSql\Mongo;
|
||||||
|
|
||||||
|
use MongoDB\Client;
|
||||||
|
|
||||||
|
final class MongoConnectionManager
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Build a MongoClientConnection from config.
|
||||||
|
*
|
||||||
|
* Supported keys:
|
||||||
|
* - uri: full MongoDB URI (takes precedence)
|
||||||
|
* - hosts: string|array host(s) (default 127.0.0.1)
|
||||||
|
* - port: int (default 27017)
|
||||||
|
* - username, password
|
||||||
|
* - authSource: string
|
||||||
|
* - replicaSet: string
|
||||||
|
* - tls: bool
|
||||||
|
* - uriOptions: array (MongoDB URI options)
|
||||||
|
* - driverOptions: array (MongoDB driver options)
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
public static function make(array $config): MongoClientConnection
|
||||||
|
{
|
||||||
|
$uri = (string)($config['uri'] ?? '');
|
||||||
|
$uriOptions = (array)($config['uriOptions'] ?? []);
|
||||||
|
$driverOptions = (array)($config['driverOptions'] ?? []);
|
||||||
|
|
||||||
|
if ($uri === '') {
|
||||||
|
$hosts = $config['hosts'] ?? ($config['host'] ?? '127.0.0.1');
|
||||||
|
$port = (int)($config['port'] ?? 27017);
|
||||||
|
$hostsStr = '';
|
||||||
|
if (is_array($hosts)) {
|
||||||
|
$parts = [];
|
||||||
|
foreach ($hosts as $h) { $parts[] = $h . ':' . $port; }
|
||||||
|
$hostsStr = implode(',', $parts);
|
||||||
|
} else {
|
||||||
|
$hostsStr = (string)$hosts . ':' . $port;
|
||||||
|
}
|
||||||
|
$user = isset($config['username']) ? (string)$config['username'] : '';
|
||||||
|
$pass = isset($config['password']) ? (string)$config['password'] : '';
|
||||||
|
$auth = ($user !== '' && $pass !== '') ? ($user . ':' . $pass . '@') : '';
|
||||||
|
|
||||||
|
$query = [];
|
||||||
|
if (!empty($config['authSource'])) { $query['authSource'] = (string)$config['authSource']; }
|
||||||
|
if (!empty($config['replicaSet'])) { $query['replicaSet'] = (string)$config['replicaSet']; }
|
||||||
|
if (isset($config['tls'])) { $query['tls'] = $config['tls'] ? 'true' : 'false'; }
|
||||||
|
$qs = $query ? ('?' . http_build_query($query)) : '';
|
||||||
|
|
||||||
|
$uri = 'mongodb://' . $auth . $hostsStr . '/' . $qs;
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = new Client($uri, $uriOptions, $driverOptions);
|
||||||
|
return new MongoClientConnection($client);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
tests/MongoAdapterTest.php
Normal file
77
tests/MongoAdapterTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pairity\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Pairity\NoSql\Mongo\MongoConnectionManager;
|
||||||
|
|
||||||
|
final class MongoAdapterTest extends TestCase
|
||||||
|
{
|
||||||
|
private function hasMongoExt(): bool
|
||||||
|
{
|
||||||
|
return extension_loaded('mongodb');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCrudCycle(): void
|
||||||
|
{
|
||||||
|
if (!$this->hasMongoExt()) {
|
||||||
|
$this->markTestSkipped('ext-mongodb not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt connection; skip if server is unavailable
|
||||||
|
try {
|
||||||
|
$conn = MongoConnectionManager::make([
|
||||||
|
'host' => getenv('MONGO_HOST') ?: '127.0.0.1',
|
||||||
|
'port' => (int)(getenv('MONGO_PORT') ?: 27017),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = 'pairity_test';
|
||||||
|
$col = 'widgets';
|
||||||
|
|
||||||
|
// Clean up any leftovers
|
||||||
|
try {
|
||||||
|
foreach ($conn->find($db, $col, []) as $doc) {
|
||||||
|
$conn->deleteOne($db, $col, ['_id' => $doc['_id']]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->markTestSkipped('Mongo operations unavailable: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
$id = $conn->insertOne($db, $col, [
|
||||||
|
'name' => 'Widget',
|
||||||
|
'qty' => 5,
|
||||||
|
'tags' => ['a','b'],
|
||||||
|
]);
|
||||||
|
$this->assertNotEmpty($id, 'Inserted _id should be returned');
|
||||||
|
|
||||||
|
// Find by id
|
||||||
|
$found = $conn->find($db, $col, ['_id' => $id]);
|
||||||
|
$this->assertNotEmpty($found, 'Should find inserted doc');
|
||||||
|
$this->assertSame('Widget', $found[0]['name'] ?? null);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
$modified = $conn->updateOne($db, $col, ['_id' => $id], ['$set' => ['qty' => 7]]);
|
||||||
|
$this->assertGreaterThanOrEqual(0, $modified);
|
||||||
|
$after = $conn->find($db, $col, ['_id' => $id]);
|
||||||
|
$this->assertSame(7, $after[0]['qty'] ?? null);
|
||||||
|
|
||||||
|
// Aggregate pipeline
|
||||||
|
$agg = $conn->aggregate($db, $col, [
|
||||||
|
['$match' => ['qty' => 7]],
|
||||||
|
['$project' => ['name' => 1, 'qty' => 1]],
|
||||||
|
]);
|
||||||
|
$this->assertNotEmpty($agg);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
$deleted = $conn->deleteOne($db, $col, ['_id' => $id]);
|
||||||
|
$this->assertGreaterThanOrEqual(1, $deleted);
|
||||||
|
$remaining = $conn->find($db, $col, ['_id' => $id]);
|
||||||
|
$this->assertCount(0, $remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
tests/MongoDaoTest.php
Normal file
82
tests/MongoDaoTest.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pairity\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Pairity\NoSql\Mongo\MongoConnectionManager;
|
||||||
|
use Pairity\NoSql\Mongo\AbstractMongoDao;
|
||||||
|
use Pairity\Model\AbstractDto;
|
||||||
|
|
||||||
|
final class MongoDaoTest extends TestCase
|
||||||
|
{
|
||||||
|
private function hasMongoExt(): bool
|
||||||
|
{
|
||||||
|
return extension_loaded('mongodb');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCrudViaDao(): void
|
||||||
|
{
|
||||||
|
if (!$this->hasMongoExt()) {
|
||||||
|
$this->markTestSkipped('ext-mongodb not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect (skip if server unavailable)
|
||||||
|
try {
|
||||||
|
$conn = MongoConnectionManager::make([
|
||||||
|
'host' => getenv('MONGO_HOST') ?: '127.0.0.1',
|
||||||
|
'port' => (int)(getenv('MONGO_PORT') ?: 27017),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define DTO/DAO inline for test
|
||||||
|
$dtoClass = new class([]) extends AbstractDto {};
|
||||||
|
$dtoFqcn = get_class($dtoClass);
|
||||||
|
|
||||||
|
$dao = new class($conn, $dtoFqcn) extends AbstractMongoDao {
|
||||||
|
private string $dto;
|
||||||
|
public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
|
||||||
|
protected function collection(): string { return 'pairity_test.widgets'; }
|
||||||
|
protected function dtoClass(): string { return $this->dto; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean collection
|
||||||
|
foreach ($dao->findAllBy([]) as $doc) {
|
||||||
|
$id = (string)($doc->toArray(false)['_id'] ?? '');
|
||||||
|
if ($id !== '') { $dao->deleteById($id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
$created = $dao->insert(['name' => 'Widget', 'qty' => 5, 'tags' => ['a','b']]);
|
||||||
|
$arr = $created->toArray(false);
|
||||||
|
$this->assertArrayHasKey('_id', $arr);
|
||||||
|
$id = (string)$arr['_id'];
|
||||||
|
$this->assertNotEmpty($id);
|
||||||
|
|
||||||
|
// Find by id
|
||||||
|
$found = $dao->findById($id);
|
||||||
|
$this->assertNotNull($found);
|
||||||
|
$this->assertSame('Widget', $found->toArray(false)['name'] ?? null);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
$updated = $dao->update($id, ['qty' => 7]);
|
||||||
|
$this->assertSame(7, $updated->toArray(false)['qty'] ?? null);
|
||||||
|
|
||||||
|
// Projection, sorting, limit/skip
|
||||||
|
$list = $dao->fields('name')->sort(['name' => 1])->limit(10)->skip(0)->findAllBy([]);
|
||||||
|
$this->assertNotEmpty($list);
|
||||||
|
$this->assertArrayHasKey('name', $list[0]->toArray(false));
|
||||||
|
|
||||||
|
// Dynamic helper findOneByName
|
||||||
|
$one = $dao->findOneByName('Widget');
|
||||||
|
$this->assertNotNull($one);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
$deleted = $dao->deleteById($id);
|
||||||
|
$this->assertGreaterThanOrEqual(1, $deleted);
|
||||||
|
$this->assertNull($dao->findById($id));
|
||||||
|
}
|
||||||
|
}
|
||||||
90
tests/MongoRelationsTest.php
Normal file
90
tests/MongoRelationsTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pairity\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Pairity\NoSql\Mongo\MongoConnectionManager;
|
||||||
|
use Pairity\NoSql\Mongo\AbstractMongoDao;
|
||||||
|
use Pairity\Model\AbstractDto;
|
||||||
|
|
||||||
|
final class MongoRelationsTest extends TestCase
|
||||||
|
{
|
||||||
|
private function hasMongoExt(): bool { return \extension_loaded('mongodb'); }
|
||||||
|
|
||||||
|
public function testEagerAndLazyRelations(): void
|
||||||
|
{
|
||||||
|
if (!$this->hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); }
|
||||||
|
|
||||||
|
// Connect (skip if server unavailable)
|
||||||
|
try {
|
||||||
|
$conn = MongoConnectionManager::make([
|
||||||
|
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
|
||||||
|
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline DTO classes
|
||||||
|
$userDto = new class([]) extends AbstractDto {};
|
||||||
|
$userDtoClass = \get_class($userDto);
|
||||||
|
$postDto = new class([]) extends AbstractDto {};
|
||||||
|
$postDtoClass = \get_class($postDto);
|
||||||
|
|
||||||
|
// Inline DAOs with relations
|
||||||
|
$UserDao = new class($conn, $userDtoClass) extends AbstractMongoDao {
|
||||||
|
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
|
||||||
|
protected function collection(): string { return 'pairity_test.users_rel'; }
|
||||||
|
protected function dtoClass(): string { return $this->dto; }
|
||||||
|
protected function relations(): array { return [
|
||||||
|
'posts' => [ 'type' => 'hasMany', 'dao' => get_class($this->makePostDao()), 'foreignKey' => 'user_id', 'localKey' => '_id' ],
|
||||||
|
]; }
|
||||||
|
private function makePostDao(): object { return new class($this->getConnection(), 'stdClass') extends AbstractMongoDao {
|
||||||
|
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
|
||||||
|
protected function collection(): string { return 'pairity_test.posts_rel'; }
|
||||||
|
protected function dtoClass(): string { return $this->dto; }
|
||||||
|
}; }
|
||||||
|
};
|
||||||
|
|
||||||
|
$PostDao = new class($conn, $postDtoClass) extends AbstractMongoDao {
|
||||||
|
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
|
||||||
|
protected function collection(): string { return 'pairity_test.posts_rel'; }
|
||||||
|
protected function dtoClass(): string { return $this->dto; }
|
||||||
|
protected function relations(): array { return [
|
||||||
|
'user' => [ 'type' => 'belongsTo', 'dao' => get_class($this->makeUserDao()), 'foreignKey' => 'user_id', 'otherKey' => '_id' ],
|
||||||
|
]; }
|
||||||
|
private function makeUserDao(): object { return new class($this->getConnection(), 'stdClass') extends AbstractMongoDao {
|
||||||
|
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
|
||||||
|
protected function collection(): string { return 'pairity_test.users_rel'; }
|
||||||
|
protected function dtoClass(): string { return $this->dto; }
|
||||||
|
}; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Instantiate concrete DAOs for use
|
||||||
|
$userDao = new $UserDao($conn, $userDtoClass);
|
||||||
|
$postDao = new $PostDao($conn, $postDtoClass);
|
||||||
|
|
||||||
|
// 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 one user and two posts
|
||||||
|
$u = $userDao->insert(['email' => 'r@example.com', 'name' => 'Rel']);
|
||||||
|
$uid = (string)$u->toArray(false)['_id'];
|
||||||
|
$postDao->insert(['title' => 'A', 'user_id' => $uid]);
|
||||||
|
$postDao->insert(['title' => 'B', 'user_id' => $uid]);
|
||||||
|
|
||||||
|
// Eager load posts on users
|
||||||
|
$users = $userDao->with(['posts'])->findAllBy([]);
|
||||||
|
$this->assertNotEmpty($users);
|
||||||
|
$this->assertIsArray($users[0]->toArray(false)['posts'] ?? null);
|
||||||
|
|
||||||
|
// Lazy load belongsTo on a post
|
||||||
|
$one = $postDao->findOneBy(['title' => 'A']);
|
||||||
|
$this->assertNotNull($one);
|
||||||
|
$postDao->load($one, 'user');
|
||||||
|
$this->assertNotNull($one->toArray(false)['user'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue