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-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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
|
@ -33,7 +42,7 @@ jobs:
|
|||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: pdo, pdo_mysql, pdo_sqlite
|
||||
extensions: pdo, pdo_mysql, pdo_sqlite, mongodb
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
@ -59,5 +68,7 @@ jobs:
|
|||
MYSQL_DB: pairity
|
||||
MYSQL_USER: root
|
||||
MYSQL_PASS: root
|
||||
MONGO_HOST: 127.0.0.1
|
||||
MONGO_PORT: 27017
|
||||
run: |
|
||||
vendor/bin/phpunit --colors=always
|
||||
|
|
|
|||
47
README.md
47
README.md
|
|
@ -129,7 +129,52 @@ Notes:
|
|||
- SQL Server
|
||||
- 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
|
||||
|
||||
|
|
|
|||
48
bin/pairity
48
bin/pairity
|
|
@ -133,6 +133,9 @@ Usage:
|
|||
pairity status [--path=DIR] [--config=FILE]
|
||||
pairity reset [--config=FILE]
|
||||
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:
|
||||
DB_DRIVER, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PATH (for sqlite)
|
||||
|
|
@ -243,6 +246,51 @@ PHP;
|
|||
stdout('Created: ' . $file);
|
||||
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:
|
||||
cmd_help();
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
{ "name": "Pairity Contributors" }
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
"php": ">=8.1",
|
||||
"ext-mongodb": "*",
|
||||
"mongodb/mongodb": "^1.19"
|
||||
},
|
||||
"autoload": {
|
||||
"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 */
|
||||
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