diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75cadcd..a8180aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index f881012..0ad7e07 100644 --- a/README.md +++ b/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 diff --git a/bin/pairity b/bin/pairity index 9266803..561ce65 100644 --- a/bin/pairity +++ b/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; diff --git a/composer.json b/composer.json index 2d37d3e..c7e1d3d 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,9 @@ { "name": "Pairity Contributors" } ], "require": { - "php": ">=8.1" + "php": ">=8.1", + "ext-mongodb": "*", + "mongodb/mongodb": "^1.19" }, "autoload": { "psr-4": { diff --git a/examples/nosql/mongo_crud.php b/examples/nosql/mongo_crud.php new file mode 100644 index 0000000..201a0eb --- /dev/null +++ b/examples/nosql/mongo_crud.php @@ -0,0 +1,50 @@ + '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"; diff --git a/examples/nosql/mongo_dao_crud.php b/examples/nosql/mongo_dao_crud.php new file mode 100644 index 0000000..afdbd0f --- /dev/null +++ b/examples/nosql/mongo_dao_crud.php @@ -0,0 +1,62 @@ + '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"; +} diff --git a/examples/nosql/mongo_relations_demo.php b/examples/nosql/mongo_relations_demo.php new file mode 100644 index 0000000..439aa54 --- /dev/null +++ b/examples/nosql/mongo_relations_demo.php @@ -0,0 +1,75 @@ + [ + '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"; +} diff --git a/src/NoSql/Mongo/AbstractMongoDao.php b/src/NoSql/Mongo/AbstractMongoDao.php new file mode 100644 index 0000000..111f559 --- /dev/null +++ b/src/NoSql/Mongo/AbstractMongoDao.php @@ -0,0 +1,386 @@ +|null */ + private ?array $projection = null; // list of field names to include + /** @var array */ + private array $sortSpec = []; + private ?int $limitVal = null; + private ?int $skipVal = null; + + /** @var array */ + private array $with = []; + /** @var array> */ + private array $relationFields = []; + + public function __construct(MongoConnectionInterface $connection) + { + $this->connection = $connection; + } + + /** Collection name (e.g., "users"). */ + abstract protected function collection(): string; + + /** @return class-string */ + 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|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|Filter $filter + * @param array $options Additional options (merged after internal modifiers) + * @return array + */ + 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 $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 $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|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|Filter $filter @param array $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 $values + * @return array + */ + 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('/(?dtoClass(); + /** @var AbstractDto $dto */ + $dto = $class::fromArray($doc); + return $dto; + } + + /** @param array|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 $dtos */ + public function loadMany(array $dtos, string $relation): void + { + if (!$dtos) return; + $this->with([$relation]); + $this->attachRelations($dtos); + } + + /** @param array $parents */ + protected function attachRelations(array $parents): void + { + if (!$parents) return; + $relations = $this->relations(); + foreach ($this->with as $name) { + if (!isset($relations[$name])) continue; + $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 + } +} diff --git a/src/NoSql/Mongo/Filter.php b/src/NoSql/Mongo/Filter.php new file mode 100644 index 0000000..1ca6780 --- /dev/null +++ b/src/NoSql/Mongo/Filter.php @@ -0,0 +1,90 @@ + */ + private array $query = []; + + private function __construct(array $initial = []) + { + $this->query = $initial; + } + + public static function make(): self + { + return new self(); + } + + /** @return array */ + public function toArray(): array + { + return $this->query; + } + + public function whereEq(string $field, mixed $value): self + { + $this->query[$field] = $value; + return $this; + } + + /** @param array $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; + } +} diff --git a/src/NoSql/Mongo/IndexManager.php b/src/NoSql/Mongo/IndexManager.php new file mode 100644 index 0000000..2af44b7 --- /dev/null +++ b/src/NoSql/Mongo/IndexManager.php @@ -0,0 +1,68 @@ +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 $keys + * @param array $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> */ + 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'); + } +} diff --git a/src/NoSql/Mongo/MongoClientConnection.php b/src/NoSql/Mongo/MongoClientConnection.php new file mode 100644 index 0000000..044522e --- /dev/null +++ b/src/NoSql/Mongo/MongoClientConnection.php @@ -0,0 +1,215 @@ +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 $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 $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; + } +} diff --git a/src/NoSql/Mongo/MongoConnectionInterface.php b/src/NoSql/Mongo/MongoConnectionInterface.php index 2b84e69..13b11cc 100644 --- a/src/NoSql/Mongo/MongoConnectionInterface.php +++ b/src/NoSql/Mongo/MongoConnectionInterface.php @@ -18,4 +18,13 @@ interface MongoConnectionInterface /** @param array> $pipeline */ public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable; + + /** @param array $filter @param array $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; } diff --git a/src/NoSql/Mongo/MongoConnectionManager.php b/src/NoSql/Mongo/MongoConnectionManager.php new file mode 100644 index 0000000..f256895 --- /dev/null +++ b/src/NoSql/Mongo/MongoConnectionManager.php @@ -0,0 +1,58 @@ + $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); + } +} diff --git a/tests/MongoAdapterTest.php b/tests/MongoAdapterTest.php new file mode 100644 index 0000000..a1f70de --- /dev/null +++ b/tests/MongoAdapterTest.php @@ -0,0 +1,77 @@ +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); + } +} diff --git a/tests/MongoDaoTest.php b/tests/MongoDaoTest.php new file mode 100644 index 0000000..c8d897c --- /dev/null +++ b/tests/MongoDaoTest.php @@ -0,0 +1,82 @@ +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)); + } +} diff --git a/tests/MongoRelationsTest.php b/tests/MongoRelationsTest.php new file mode 100644 index 0000000..aaf8c9c --- /dev/null +++ b/tests/MongoRelationsTest.php @@ -0,0 +1,90 @@ +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); + } +}