Mongo updates

This commit is contained in:
Funky Waddle 2025-12-10 08:02:24 -06:00
parent ae80d9bde1
commit 55d256506a
16 changed files with 1371 additions and 3 deletions

View file

@ -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

View file

@ -129,7 +129,52 @@ Notes:
- SQL Server - SQL Server
- Oracle - Oracle
NoSQL: a minimal inmemory 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` (inmemory) remains for experimentation without external deps.
### MongoDB (production adapter)
Pairity includes a productionready 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 24hex 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

View file

@ -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;

View file

@ -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": {

View 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";

View 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";
}

View 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";
}

View 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
}
}

View 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;
}
}

View 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');
}
}

View 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;
}
}

View file

@ -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;
} }

View 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);
}
}

View 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
View 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));
}
}

View 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);
}
}