├── LICENSE ├── autoload.php ├── bootstrap.php ├── composer.json └── lib ├── MongoHybrid ├── ClientWrapper.php └── Contracts │ ├── ClientInterface.php │ ├── CollectionInterface.php │ ├── CursorInterface.php │ ├── DriverInterface.php │ └── ResultInterface.php └── MongoSql ├── Collection.php ├── Cursor.php ├── Driver ├── Driver.php ├── MysqlDriver.php └── PgsqlDriver.php ├── DriverException.php ├── QueryBuilder ├── MysqlQueryBuilder.php ├── PgsqlQueryBuilder.php └── QueryBuilder.php └── ResultIterator.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Piotr Konieczny, [https://piotr.cz](https://piotr.cz) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | retrieve('config/database'); 14 | 15 | // Skip when server other than sqldriver 16 | if ($dbConfig['server'] !== Driver::SERVER_NAME) { 17 | return; 18 | } 19 | 20 | /** 21 | * Register on bootstrap 22 | * @var \LimeExtra\App $this 23 | * @var \LimeExtra\App $app 24 | * @var \Lime\Module $module 25 | * 26 | * Note: classes may be autoloaded after app has booted which happens after module is booted 27 | */ 28 | $app->on('cockpit.bootstrap', function () use ($dbConfig): ?bool { 29 | // Overwrite storage in registry 30 | $this->set('storage', function () use ($dbConfig): MongoHybridClientWrapper { 31 | static $client = null; 32 | 33 | if ($client === null) { 34 | $client = new MongoHybridClientWrapper( 35 | $dbConfig['server'], 36 | $dbConfig['options'], 37 | $dbConfig['driverOptions'] 38 | ); 39 | } 40 | 41 | return $client; 42 | }); 43 | 44 | return true; 45 | }, $dbConfig['options']['bootstrapPriority'] ?? 999); 46 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "piotr-cz/cockpit-sql-driver", 3 | "type": "cockpit-module", 4 | "description": "SQL Driver for Cockpit CMS", 5 | "keywords": ["cockpit", "Database driver", "SQL", "MariaDB", "MySQL", "PostgreSQL"], 6 | "homepage": "https://github.com/piotr-cz/cockpit-sql-driver", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Piotr Konieczny", 11 | "email": "hello@piotr.cz", 12 | "homepage": "https://www.piotr.cz" 13 | } 14 | ], 15 | "require": { 16 | "php": ">= 7.1", 17 | "ext-json": "*", 18 | "ext-pdo": "*", 19 | "composer/installers": "^1.2" 20 | }, 21 | "require-dev": { 22 | "aheinze/cockpit": "0.*", 23 | "friendsofphp/php-cs-fixer": "^2.16.1", 24 | "phpunit/phpunit": "^7.5" 25 | }, 26 | "suggest": { 27 | "ext-pdo_mysql": "For MySQL support", 28 | "ext-pdo_pgsql": "For PostgreSQL support", 29 | "ext-mongodb": "For running tests with MongoDB", 30 | "aheinze/cockpit": "Please install Cockpit before installing this addon" 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Test\\": "tests/", 35 | "": [ 36 | "vendor/aheinze/cockpit/lib/", 37 | "vendor/aheinze/cockpit/vendor/" 38 | ] 39 | }, 40 | "exclude-from-classmap": "vendor/aheinze/cockpit/lib/vendor/" 41 | }, 42 | "config": { 43 | "platform": { 44 | "php": "7.1.30", 45 | "ext-mongodb": "1.5.0" 46 | }, 47 | "sort-packages": true 48 | }, 49 | "extra": { 50 | "installer-name": "SqlDriver" 51 | }, 52 | "scripts": { 53 | "test": [ 54 | "@test:phpcs", 55 | "@test:phpunit" 56 | ], 57 | "test:phpunit": "phpunit", 58 | "test:phpcs": "php-cs-fixer fix -vv --diff --dry-run", 59 | "phpcs-fix": "php-cs-fixer fix -vv --diff" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/MongoHybrid/ClientWrapper.php: -------------------------------------------------------------------------------- 1 | driver) { 27 | throw new DriverException(sprintf('Could not initialize driver %s', $server)); 28 | } 29 | 30 | return; 31 | } 32 | 33 | // Validate connection 34 | if (empty($options['connection'])) { 35 | throw new DriverException(sprintf('SQL driver not set up')); 36 | } 37 | 38 | // Resolve drivers' FQCN 39 | $fqcn = sprintf('MongoSql\Driver\%sDriver', ucfirst($options['connection'])); 40 | 41 | if (!class_exists($fqcn)) { 42 | throw new DriverException(sprintf('SQL driver for %s not found', $options['connection'])); 43 | } 44 | 45 | // Create new driver 46 | $this->driver = new $fqcn($options, $driverOptions); 47 | 48 | // Set same type as MongoLite 49 | $this->type = 'mongolite'; 50 | } 51 | 52 | /** 53 | * Check if driver is subclass of given class 54 | * useful for feature checking, should use interfaces 55 | * 56 | * @param string $className 57 | * @return bool 58 | */ 59 | public function driverImplements(string $className): bool 60 | { 61 | return $this->driver instanceof $className; 62 | } 63 | 64 | /** 65 | * Check if driver has method 66 | * useful for feature checking 67 | * 68 | * @param string $methodName 69 | * @return bool 70 | */ 71 | public function driverHasMethod(string $methodName): bool 72 | { 73 | return method_exists($this->driver, $methodName); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/MongoHybrid/Contracts/ClientInterface.php: -------------------------------------------------------------------------------- 1 | app->storage->getCollection('foobar')->count()` 9 | */ 10 | interface CollectionInterface 11 | { 12 | /** 13 | * @deprecated 14 | */ 15 | public function drop(): bool; 16 | 17 | /** 18 | * Count items in collection 19 | * Used in \install\index.php 20 | * 21 | * @param array|callable [$count] 22 | * @return int 23 | */ 24 | public function count($filter = []): int; 25 | 26 | /** 27 | * Insert many documents 28 | * 29 | * @param array $documents 30 | * @return bool 31 | */ 32 | public function insertMany(array &$documents): bool; 33 | 34 | /** 35 | * Not used in Cockpit 36 | * Not part of MongoDB 37 | * @todo mark MongoHybrid\Client::renameCollection as deprecated 38 | * 39 | * @param string $newName 40 | */ 41 | // public function renameCollection(string $newName): void; 42 | } 43 | -------------------------------------------------------------------------------- /lib/MongoHybrid/Contracts/CursorInterface.php: -------------------------------------------------------------------------------- 1 | storage->update 16 | */ 17 | interface DriverInterface 18 | { 19 | //// Used by MongoHybrid\Client 20 | 21 | /** 22 | * Get collection 23 | * 24 | * @param string name 25 | * @param string [$db] 26 | * @return CollectionInterface 27 | */ 28 | public function getCollection(string $name, string $db = null): CollectionInterface; 29 | 30 | //// Used by MongoHybrid\Client or as proxy 31 | 32 | /** 33 | * Find one document in collection 34 | * Used in modules\Cockpit\cli\account\create.php, ... 35 | * 36 | * @param string $collectionId 37 | * @param array|callable [$filter] 38 | * @return array|null 39 | */ 40 | public function findOne(string $collectionId, $filter = []): ?array; 41 | 42 | /** 43 | * Find document in collection by it's id 44 | * Used in lib\MongoHybrid\ResultSet::hasOne 45 | * 46 | * @param string $collectionId 47 | * @param string $docId 48 | * @return array|null 49 | */ 50 | public function findOneById(string $collectionId, string $docId): ?array; 51 | 52 | /** 53 | * Save (insert or update) new document into collection 54 | * 55 | * @param string $collectionId - Full collection id 56 | * @param array &$doc 57 | * @param bool $isCreate - true to replace document, false to update 58 | * @return bool 59 | */ 60 | public function save(string $collectionId, array &$doc, bool $isCreate = false): bool; 61 | 62 | /** 63 | * Remove documents from collection 64 | * 65 | * @param string $collectionId 66 | * @param array|callable $filter 67 | */ 68 | public function remove(string $collectionId, $filter): bool; 69 | 70 | /** 71 | * Insert new document or multiple documents into collection 72 | * 73 | * @param string $collectionId - Full collection id 74 | * @param array &$doc - Document or array of documents to insert 75 | * @return bool 76 | */ 77 | public function insert(string $collectionId, array &$doc): bool; 78 | 79 | /** 80 | * Used in modules\Cockpit\cli\import\accounts.php 81 | */ 82 | public function count(string $collectionId, $filter = []): int; 83 | 84 | /** 85 | * Drop collection 86 | * Cockpit MongoLite implementation is broken 87 | * 88 | * Used in 89 | * - modules\Collections\cli\flush\accounts.php 90 | * - modules\Collections\cli\fllush\assets.php 91 | * - modules\Collections\cli\flush\collections.php 92 | * - modules\Collections\bootstrap.php 93 | * - modules\Forms\cli\flush\forms.php 94 | * - ... 95 | * 96 | * @param string $collectionId 97 | */ 98 | public function dropCollection(string $collectionId): bool; 99 | 100 | //// Used as proxy 101 | 102 | /** 103 | * Update documents in collection matching filter 104 | * Used in modules\Cockpit\module\auth.php 105 | * 106 | * @param string $collectionId 107 | * @param array|callable $filter 108 | * @param array $data 109 | */ 110 | public function update(string $collectionId, $filter, array $data); 111 | 112 | /** 113 | * Find documents in collection 114 | * 115 | * @param string $collectionId 116 | * @param array [$options] { 117 | * @var array [$filter] 118 | * @var array [$fields] 119 | * @var array [$sort] 120 | * @var int [$limit] 121 | * @var int [$skip] 122 | * } 123 | * @return ResultSet 124 | */ 125 | public function find(string $collectionId, array $options = []); 126 | 127 | /** 128 | * Remove field from collection documents 129 | * @since https://github.com/agentejo/cockpit/commit/504bc559af08b8e22c5dc5c15ef27bf13192ed42 130 | * 131 | * @param string $collectionId 132 | * @param string $field 133 | */ 134 | public function removeField(string $collectionId, string $field): void; 135 | 136 | /** 137 | * Rename field in collection documents 138 | * @since https://github.com/agentejo/cockpit/commit/76f90543074705d941df48e9b1d3ffec3873c30a 139 | * 140 | * @param string $collectionId 141 | * @param string $field 142 | * @param string $newField 143 | */ 144 | public function renameField(string $collectionId, string $field, string $newField): void; 145 | } 146 | -------------------------------------------------------------------------------- /lib/MongoHybrid/Contracts/ResultInterface.php: -------------------------------------------------------------------------------- 1 | collection name] 17 | */ 18 | public function hasOne(iterable $collections); 19 | 20 | /** 21 | * Populate each document with with related ones from given collections 22 | * 23 | * @param iterable $collections - Format [collection name => foreign key] 24 | */ 25 | public function hasMany(iterable $collections); 26 | 27 | /** 28 | * Convert to array 29 | * Note: should be able to typecast to array 30 | * 31 | * @return array 32 | */ 33 | public function toArray(): array; 34 | } 35 | -------------------------------------------------------------------------------- /lib/MongoSql/Collection.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 74 | $this->queryBuilder = $queryBuilder; 75 | $this->collectionName = $collectionName; 76 | $this->handleCollectionDrop = $handleCollectionDrop; 77 | 78 | $this->createIfNotExists(); 79 | } 80 | 81 | /** 82 | * Create factory callable which takes on only $collectionName parameter 83 | * 84 | * @param \PDO $connection 85 | * @param \MongoSql\QueryBuilder\QueryBuilder $queryBuilder 86 | * @param callable [$handleCollectionDrop] 87 | * @return callable 88 | */ 89 | public static function factory( 90 | PDO $connection, 91 | QueryBuilder $queryBuilder, 92 | callable $handleCollectionDrop = null 93 | ): callable { 94 | return function (string $collectionName) use ($connection, $queryBuilder, $handleCollectionDrop): self { 95 | return new static( 96 | $connection, 97 | $queryBuilder, 98 | $collectionName, 99 | $handleCollectionDrop 100 | ); 101 | }; 102 | } 103 | 104 | /** 105 | * Return collection namespace 106 | * 107 | * @return string 108 | */ 109 | public function __toString(): string 110 | { 111 | return $this->collectionName; 112 | } 113 | 114 | /** 115 | * Find document 116 | * 117 | * @param array|callable $filter 118 | * @param array [$options] { 119 | * @var array [$sort] 120 | * @var int [$limit] 121 | * @var int [$skip] 122 | * @var array [$projection] 123 | * } 124 | * @return Cursor 125 | * 126 | * Note: deprecated usage 127 | * `$collection->find()->limit(1)` 128 | * in favor of 129 | * `$collection->find([], ['limit' => 1])` 130 | */ 131 | public function find($filter, array $options = []): CursorInterface 132 | { 133 | return new Cursor($this->connection, $this->queryBuilder, $this->collectionName, $filter, $options); 134 | } 135 | 136 | /** 137 | * Find one document 138 | * 139 | * @param array|callable $filter 140 | * @param array [$options] 141 | * @return array|null 142 | */ 143 | public function findOne($filter, array $options = []): ?array 144 | { 145 | $results = $this->find($filter, array_merge($options, [ 146 | 'limit' => 1 147 | ]))->toArray(); 148 | 149 | return array_shift($results); 150 | } 151 | 152 | /** 153 | * @inheritdoc 154 | */ 155 | public function insertMany(array &$documents): bool 156 | { 157 | $stmt = $this->connection->prepare( 158 | <<queryBuilder->qi($this->collectionName)} ("document") 162 | 163 | VALUES ( 164 | :data 165 | ) 166 | SQL 167 | ); 168 | 169 | foreach ($documents as &$document) { 170 | $document['_id'] = createMongoDbLikeId(); 171 | 172 | $stmt->execute([':data' => QueryBuilder::jsonEncode($document)]); 173 | } 174 | 175 | return true; 176 | } 177 | 178 | /** 179 | * Insert new document into collection 180 | * 181 | * @param array &$document 182 | * @return bool 183 | */ 184 | public function insertOne(&$document): bool 185 | { 186 | // Trick to pass document by reference 187 | $documents = [$document]; 188 | 189 | $this->insertMany($documents); 190 | 191 | $document = array_pop($documents); 192 | 193 | return true; 194 | } 195 | 196 | /** 197 | * Update documents by merging with it's data 198 | * 199 | * Ideally should use one query to update all rows with: 200 | * MySQL & MariaDB (10.2.25/ 10.3.15/ 10.4.5) only: 201 | * `SET "document" = JSON_MERGE_PATCH("document", :data)` 202 | * PostgreSQL 9.5+ only: 203 | * `SET "document" = "document" || :data::jsonb` 204 | * 205 | * @param array|callable $filter 206 | * @param array $update - Data to apply to the matched documents 207 | * @param array [$options] 208 | * @return bool 209 | */ 210 | public function updateMany($filter, array $update, array $options = []): bool 211 | { 212 | $stmt = $this->connection->prepare( 213 | <<queryBuilder->qi($this->collectionName)} 217 | 218 | SET 219 | "document" = :data 220 | 221 | WHERE 222 | {$this->queryBuilder->createPathSelector('_id')} = :_id 223 | SQL 224 | ); 225 | 226 | /* Note: Cannot use Traversable as MySQL client doesn't allow running more than one query at a time 227 | * (General error: 2014 Cannot execute queries while other unbuffered queries are active.) 228 | * Alternatively coud set PDO:MYSQL_ATTR_USE_BUFFERED_QUERY => true 229 | * see https://stackoverflow.com/a/17582620/1012616 230 | */ 231 | foreach ($this->find($filter, $options)->toArray() as $item) { 232 | $stmt->execute([ 233 | ':_id' => $item['_id'], 234 | ':data' => QueryBuilder::jsonEncode(array_merge($item, $update)), 235 | ]); 236 | } 237 | 238 | return true; 239 | } 240 | 241 | /** 242 | * Update document by merging with it's data 243 | * 244 | * @param array|callable $filter 245 | * @param array $update 246 | * @return bool 247 | */ 248 | public function updateOne($filter, array $update): bool 249 | { 250 | return $this->updateMany($filter, $update, [ 251 | 'limit' => 1 252 | ]); 253 | } 254 | 255 | /** 256 | * Replace document 257 | * 258 | * @param array $filter 259 | * @param array $replace - Data to replace to the matched documents 260 | * @return bool 261 | */ 262 | public function replaceOne(array $filter, array $replace): bool 263 | { 264 | // Note: UPDATE .. LIMIT Won't work for PostgreSQL 265 | $stmt = $this->connection->prepare( 266 | <<queryBuilder->qi($this->collectionName)} 270 | 271 | SET 272 | "document" = :data 273 | 274 | {$this->queryBuilder->buildWhere($filter)} 275 | SQL 276 | ); 277 | 278 | $stmt->execute([':data' => QueryBuilder::jsonEncode($replace)]); 279 | 280 | return true; 281 | } 282 | 283 | /** 284 | * Delete documents 285 | * 286 | * @param array [$filter] 287 | * @return bool 288 | */ 289 | public function deleteMany(array $filter = []): bool 290 | { 291 | $stmt = $this->connection->prepare( 292 | <<queryBuilder->qi($this->collectionName)} 296 | 297 | {$this->queryBuilder->buildWhere($filter)} 298 | SQL 299 | ); 300 | 301 | $stmt->execute(); 302 | 303 | return true; 304 | } 305 | 306 | /** 307 | * Count documents 308 | * 309 | * @param array|callable [$filter] 310 | * @return int 311 | */ 312 | public function countDocuments($filter = []): int 313 | { 314 | // On user defined function must use find to evaluate each item 315 | if (is_callable($filter)) { 316 | return iterator_count($this->find($filter)); 317 | } 318 | 319 | $stmt = $this->connection->prepare( 320 | <<queryBuilder->qi($this->collectionName)} 327 | 328 | {$this->queryBuilder->buildWhere($filter)} 329 | SQL 330 | ); 331 | 332 | $stmt->execute(); 333 | 334 | return (int) $stmt->fetchColumn(); 335 | } 336 | 337 | /** 338 | * Count documents 339 | * @note Deprecated in MongoDb 1.4 in favor of countDocuments 340 | * @param array|callable [$filter] 341 | * @return int 342 | */ 343 | public function count($filter = []): int 344 | { 345 | // trigger_error('Collection::count is deprecated. Use Collection::countDocuments instead', E_DEPRECATED); 346 | 347 | return $this->countDocuments($filter); 348 | } 349 | 350 | /** 351 | * @inheritdoc 352 | */ 353 | public function drop(): bool 354 | { 355 | $stmt = $this->connection->prepare( 356 | <<queryBuilder->qi($this->collectionName)} 360 | SQL 361 | ); 362 | 363 | $stmt->execute(); 364 | 365 | if ($this->handleCollectionDrop) { 366 | ($this->handleCollectionDrop)($this->collectionName); 367 | } 368 | 369 | return true; 370 | } 371 | 372 | /** 373 | * Create table if does not exist 374 | */ 375 | protected function createIfNotExists(): void 376 | { 377 | // Create one 378 | $sql = $this->queryBuilder->buildCreateTable($this->collectionName); 379 | 380 | $this->connection->exec($sql); 381 | 382 | return; 383 | } 384 | } 385 | 386 | /** 387 | * @see MongoLite\Database 388 | */ 389 | function createMongoDbLikeId() 390 | { 391 | // based on https://gist.github.com/h4cc/9b716dc05869296c1be6 392 | 393 | $timestamp = \microtime(true); 394 | $hostname = \php_uname('n'); 395 | $processId = \getmypid(); 396 | $id = \random_int(10, 1000); 397 | $result = ''; 398 | 399 | // Building binary data. 400 | $bin = \sprintf( 401 | '%s%s%s%s', 402 | \pack('N', $timestamp), 403 | \substr(md5($hostname), 0, 3), 404 | \pack('n', $processId), 405 | \substr(\pack('N', $id), 1, 3) 406 | ); 407 | 408 | // Convert binary to hex. 409 | for ($i = 0; $i < 12; $i++) { 410 | $result .= \sprintf('%02x', ord($bin[$i])); 411 | } 412 | 413 | return $result; 414 | } 415 | -------------------------------------------------------------------------------- /lib/MongoSql/Cursor.php: -------------------------------------------------------------------------------- 1 | null, 46 | 'limit' => null, 47 | 'skip' => null, 48 | 'projection' => null, 49 | ]; 50 | 51 | /** 52 | * Constructor 53 | * 54 | * @param \PDO $connection 55 | * @param string $collectionName 56 | * @param array|callable $filter 57 | * @param array [$options] { 58 | * @var array [$sort] 59 | * @var int [$limit] 60 | * @var int [$skip] 61 | * @var array [$projection] 62 | * } 63 | */ 64 | public function __construct( 65 | PDO $connection, 66 | QueryBuilder $queryBuilder, 67 | string $collectionName, 68 | $filter = [], 69 | array $options = [] 70 | ) { 71 | $this->connection = $connection; 72 | $this->queryBuilder = $queryBuilder; 73 | 74 | $this->collectionName = $collectionName; 75 | $this->filter = $filter; 76 | $this->options = array_merge($this->options, $options); 77 | } 78 | 79 | /** 80 | * @inheritdoc 81 | */ 82 | public function toArray(): array 83 | { 84 | return iterator_to_array($this->getIterator()); 85 | } 86 | 87 | /** 88 | * Build SQL query 89 | * 90 | * @return string 91 | */ 92 | public function getSql(): string 93 | { 94 | $sqlWhere = !is_callable($this->filter) 95 | ? $this->queryBuilder->buildWhere($this->filter) 96 | : null; 97 | 98 | $sqlOrderBy = $this->queryBuilder->buildOrderBy($this->options['sort']); 99 | 100 | $sqlLimit = !is_callable($this->filter) 101 | ? $this->queryBuilder->buildLimit($this->options['limit'], $this->options['skip']) 102 | : null; 103 | 104 | return <<queryBuilder->qi($this->collectionName)} 111 | 112 | {$sqlWhere} 113 | {$sqlOrderBy} 114 | {$sqlLimit} 115 | SQL; 116 | } 117 | 118 | /** 119 | * Get Traversable 120 | * IteratorAggregate implementation 121 | * 122 | * @see {@link https://www.php.net/manual/en/class.generator.php} 123 | * 124 | * @return \Traversable 125 | * @throws \PDOException 126 | */ 127 | public function getIterator(): Traversable 128 | { 129 | $sql = $this->getSql(); 130 | 131 | try { 132 | /* Query without parameters (via PDO::prepare) to avoid problems with reserved characters (? and :) 133 | * driver option PDO::ATTR_EMULATE_PREPARES must be set to true - see {@link https://bugs.php.net/bug.php?id=74220} 134 | * This is fixed in php 7.4 {@link https://wiki.php.net/rfc/pdo_escape_placeholders}, 135 | * {@link https://www.php.net/manual/en/migration74.new-features.php#migration74.new-features.pdo} 136 | */ 137 | 138 | // $stmt = $this->connection->prepare($sql, [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_COLUMN]); 139 | // @throws \PDOException: SQLSTATE[HY093]: Invalid parameter number: no parameters were bound on ATTR_EMULATE_PREPARES true 140 | // @throws \PDOException: SQLSTATE[42601]: Syntax error: 7 ERROR: syntax error at or near "$1" on ATTR_EMULATE_PREPARES false 141 | // $stmt->execute(); 142 | 143 | // @throws \PDOException: SQLSTATE[42601]: Syntax error: 7 ERROR: syntax error at or near "$1" on ATTR_EMULATE_PREPARES false 144 | $stmt = $this->connection->query($sql, PDO::FETCH_COLUMN, 0); 145 | } catch (PDOException $pdoException) { 146 | // Rethrow exception with query 147 | throw new DriverException( 148 | sprintf('PDOException while running query %s', $sql), 149 | // Some PostgresSQL codes are strings (22P02) 150 | (int) $pdoException->getCode(), 151 | $pdoException 152 | ); 153 | } 154 | 155 | $it = mapIterator($stmt, [QueryBuilder::class, 'jsonDecode']); 156 | 157 | if (is_callable($this->filter)) { 158 | $it = new CallbackFilterIterator($it, $this->filter); 159 | // Note: Rewinding LimitIterator empties it 160 | $it = new LimitIterator($it, $this->options['skip'] ?? 0, $this->options['limit'] ?? -1); 161 | } 162 | 163 | $projection = static::compileProjection($this->options['projection']); 164 | 165 | return mapIterator($it, [static::class, 'applyDocumentProjection'], $projection); 166 | } 167 | 168 | /** 169 | * Compile projection 170 | * 171 | * @param array [$projection] 172 | * @return array 173 | */ 174 | protected static function compileProjection(array $projection = null): ?array 175 | { 176 | if (empty($projection)) { 177 | return null; 178 | } 179 | 180 | $include = array_filter($projection); 181 | $exclude = array_diff($projection, $include); 182 | 183 | return [ 184 | 'include' => $include, 185 | 'exclude' => $exclude, 186 | ]; 187 | } 188 | 189 | /** 190 | * Apply projection to document 191 | * 192 | * @param array|null $document 193 | * @param array [$projection] { 194 | * @var array $exclude 195 | * @var array $include 196 | * } 197 | * @return array|null 198 | */ 199 | public static function applyDocumentProjection(?array $document, array $projection = null): ?array 200 | { 201 | if (empty($document) || empty($projection)) { 202 | return $document; 203 | } 204 | 205 | $id = $document['_id']; 206 | $include = $projection['include']; 207 | $exclude = $projection['exclude']; 208 | 209 | // Remove keys 210 | if (!empty($exclude)) { 211 | $document = array_diff_key($document, $exclude); 212 | } 213 | 214 | // Keep keys (not sure why MongoLite::cursor uses custom function array_key_intersect) 215 | if (!empty($include)) { 216 | $document = array_intersect_key($document, $include); 217 | } 218 | 219 | // Don't remove `_id` via include unless it's explicitly excluded 220 | if (!isset($exclude['_id'])) { 221 | $document['_id'] = $id; 222 | } 223 | 224 | return $document; 225 | } 226 | } 227 | 228 | /** 229 | * Apply callback to every element 230 | * 231 | * @param iterable $iterable 232 | * @param callable $function 233 | * @param mixed ...$args - Custom arguments 234 | * @return \Generator 235 | */ 236 | function mapIterator(iterable $iterable, callable $function, ...$args): Generator 237 | { 238 | foreach ($iterable as $key => $value) { 239 | yield $key => $function($value, ...$args); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /lib/MongoSql/Driver/Driver.php: -------------------------------------------------------------------------------- 1 | PDO::ERRMODE_EXCEPTION, 39 | // Set default fetch mode 40 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_COLUMN, 41 | // Use prepares to avoid parsing query selectors as placeholders 42 | PDO::ATTR_EMULATE_PREPARES => true, 43 | ]; 44 | 45 | /** @type \PDO - Database connection */ 46 | protected $connection; 47 | 48 | /** @var \MongoSql\Collection[] - Collections cache */ 49 | protected $collections = []; 50 | 51 | /** @var \MongoSql\QueryBuilder\QueryBuilder */ 52 | protected $queryBuilder; 53 | 54 | /** @var string - Collections table prefix */ 55 | protected $tablePrefix = ''; 56 | 57 | /** 58 | * Constructor 59 | * 60 | * @param array $options { 61 | * @var string $connection 62 | * @var string [$host] 63 | * @var int [$port] 64 | * @var string $dbname 65 | * @var string $username 66 | * @var string $password 67 | * @var string [$charset] 68 | * @var string [$tablePrefix] 69 | * } 70 | * @param array [$driverOptions] 71 | * @throws \MongoSql\DriverException 72 | */ 73 | public function __construct(array $options, array $driverOptions = []) 74 | { 75 | try { 76 | $this->connection = static::createConnection($options, $driverOptions + static::$defaultDriverOptions); 77 | } catch (PDOException $pdoException) { 78 | throw new DriverException(sprintf('PDO connection failed: %s', $pdoException->getMessage()), 0, $pdoException); 79 | } 80 | 81 | $this->queryBuilder = QueryBuilder::createFromPdo($this->connection); 82 | $this->tablePrefix = $options['tablePrefix'] ?? ''; 83 | 84 | $this->assertIsDbSupported(); 85 | } 86 | 87 | /** 88 | * Close connection 89 | */ 90 | public function __destruct() 91 | { 92 | $this->connection = null; 93 | } 94 | 95 | /** 96 | * Create PDO connection 97 | * 98 | * @param array $options 99 | * @param array [$driverOptions] 100 | * @return \PDO 101 | * @throws \PDOException 102 | */ 103 | abstract protected static function createConnection(array $options, array $driverOptions = []): PDO; 104 | 105 | /** 106 | * Assert features are supported by database 107 | * 108 | * @throws \MongoSql\DriverException 109 | */ 110 | protected function assertIsDbSupported(): void 111 | { 112 | $pdoDriverName = $this->connection->getAttribute(PDO::ATTR_DRIVER_NAME); 113 | // Note: may also use static::DB_DRIVER_NAME; 114 | 115 | // Check for PDO Driver 116 | if (!in_array($pdoDriverName, PDO::getAvailableDrivers())) { 117 | throw new DriverException(sprintf('PDO extension for %s driver not loaded', $pdoDriverName)); 118 | } 119 | 120 | return; 121 | } 122 | 123 | /** 124 | * Assert min database server version requirement 125 | * 126 | * @param string $currentVersion 127 | * @param string $minVersion 128 | * @throws \MongoSql\DriverException 129 | */ 130 | protected static function assertIsDbVersionSupported(string $currentVersion, string $minVersion): void 131 | { 132 | if (!version_compare($currentVersion, $minVersion, '>=')) { 133 | throw new DriverException(vsprintf('Driver requires database server version >= %s, got %s', [ 134 | $minVersion, 135 | $currentVersion 136 | ])); 137 | } 138 | } 139 | 140 | /** 141 | * @inheritdoc 142 | * Note: Should type hint return to \MongoSql\Collection, but this requires PHP 7.4+ 143 | * https://wiki.php.net/rfc/covariant-returns-and-contravariant-parameters 144 | */ 145 | public function getCollection(string $name, string $db = null): CollectionInterface 146 | { 147 | $collectionId = $db 148 | ? $db . '/' . $name 149 | : $name; 150 | 151 | if (!isset($this->collections[$collectionId])) { 152 | $this->collections[$collectionId] = new Collection( 153 | $this->connection, 154 | $this->queryBuilder, 155 | $this->tablePrefix . $collectionId, 156 | [$this, 'handleCollectionDrop'] 157 | ); 158 | } 159 | 160 | return $this->collections[$collectionId]; 161 | } 162 | 163 | /** 164 | * @inheritdoc 165 | */ 166 | public function dropCollection(string $collectionId): bool 167 | { 168 | return $this->getCollection($collectionId)->drop(); 169 | } 170 | 171 | /** 172 | * Handle collection drop 173 | * 174 | * @param string $collectionName - Table prefix + Collection id 175 | */ 176 | public function handleCollectionDrop(string $collectionName): void 177 | { 178 | unset($this->collections[$collectionName]); 179 | } 180 | 181 | /** 182 | * @inheritdoc 183 | */ 184 | public function find(string $collectionId, array $criteria = [], bool $returnIterator = false) 185 | { 186 | $filter = $criteria['filter'] ?? null; 187 | 188 | $options = [ 189 | 'sort' => $criteria['sort'] ?? null, 190 | 'limit' => $criteria['limit'] ?? null, 191 | 'skip' => $criteria['skip'] ?? null, 192 | 'projection' => $criteria['fields'] ?? null, 193 | ]; 194 | 195 | $cursor = $this->getCollection($collectionId)->find($filter, $options); 196 | 197 | if ($returnIterator) { 198 | return new ResultIterator($this, $cursor); 199 | } 200 | 201 | $docs = array_values($cursor->toArray()); 202 | 203 | return new ResultSet($this, $docs); 204 | } 205 | 206 | /** 207 | * @inheritdoc 208 | */ 209 | public function findOne(string $collectionId, $filter = []): ?array 210 | { 211 | return $this->getCollection($collectionId)->findOne($filter); 212 | } 213 | 214 | /** 215 | * @inheritdoc 216 | */ 217 | public function findOneById(string $collectionId, string $docId): ?array 218 | { 219 | return $this->findOne($collectionId, ['_id' => $docId]); 220 | } 221 | 222 | /** 223 | * @inheritdoc 224 | */ 225 | public function save(string $collectionId, array &$doc, bool $isCreate = false): bool 226 | { 227 | if (empty($doc['_id'])) { 228 | return $this->insert($collectionId, $doc); 229 | } 230 | 231 | $filter = ['_id' => $doc['_id']]; 232 | 233 | if ($isCreate) { 234 | return $this->getCollection($collectionId)->replaceOne($filter, $doc); 235 | } 236 | 237 | return $this->getCollection($collectionId)->updateOne($filter, $doc); 238 | } 239 | 240 | /** 241 | * @inheritdoc 242 | */ 243 | public function insert(string $collectionId, array &$doc): bool 244 | { 245 | // Detect sequential array of documents 246 | // See MongoHybrid\Mongo::insert 247 | if (isset($doc[0])) { 248 | return $this->getCollection($collectionId)->insertMany($doc); 249 | } 250 | 251 | return $this->getCollection($collectionId)->insertOne($doc); 252 | } 253 | 254 | /** 255 | * @inheritdoc 256 | */ 257 | public function update(string $collectionId, $filter, array $data): bool 258 | { 259 | return $this->getCollection($collectionId)->updateMany($filter, $data); 260 | } 261 | 262 | /** 263 | * @inheritdoc 264 | */ 265 | public function remove(string $collectionId, $filter): bool 266 | { 267 | return $this->getCollection($collectionId)->deleteMany($filter); 268 | } 269 | 270 | /** 271 | * @inheritdoc 272 | */ 273 | public function count(string $collectionId, $filter = []): int 274 | { 275 | return $this->getCollection($collectionId)->count($filter); 276 | } 277 | 278 | /** 279 | * @inheritdoc 280 | */ 281 | public function removeField(string $collectionId, string $field, $filter = []): void 282 | { 283 | $docs = $this->find($collectionId, ['filter' => $filter]); 284 | 285 | foreach ($docs as $doc) { 286 | if (!isset($doc[$field])) { 287 | continue; 288 | } 289 | 290 | unset($doc[$field]); 291 | 292 | $this->save($collectionId, $doc, true); 293 | } 294 | 295 | return; 296 | } 297 | 298 | /** 299 | * @inheritdoc 300 | */ 301 | public function renameField(string $collectionId, string $field, string $newField, $filter = []): void 302 | { 303 | $docs = $this->find($collectionId, ['filter' => $filter]); 304 | 305 | foreach ($docs as $doc) { 306 | if (!isset($doc[$field])) { 307 | continue; 308 | } 309 | 310 | $doc[$newField] = $doc[$field]; 311 | 312 | unset($doc[$field]); 313 | 314 | $this->save($collectionId, $doc, true); 315 | } 316 | 317 | return; 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /lib/MongoSql/Driver/MysqlDriver.php: -------------------------------------------------------------------------------- 1 | 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;', 42 | // Use unbuffered query to get results one by one 43 | PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false, 44 | ] 45 | ); 46 | 47 | /* Set sql_mode after connection has started to ISO/IEC 9075 48 | * https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html 49 | * https://mariadb.com/kb/en/library/sql-mode/ 50 | */ 51 | $connection->exec("SET sql_mode = 'ANSI';"); 52 | 53 | return $connection; 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | * 59 | * Version string examples: 60 | * - MySQL: `5.7.27-0ubuntu0.18.04.1` 61 | * - MariaDB: `5.5.5-10.2.26-MariaDB-1:10.2.26+maria~bionic` 62 | * - MariaDB: `5.5.5-10.4.18-MariaDB-cll-lve` 63 | * - MariaDB: `10.4.18-MariaDB-cll-lve` 64 | */ 65 | protected function assertIsDbSupported(): void 66 | { 67 | parent::assertIsDbSupported(); 68 | 69 | $fullVersion = $this->connection->getAttribute(PDO::ATTR_SERVER_VERSION); 70 | $fullVersionFragments = explode('-', $fullVersion); 71 | 72 | $currentVersion = array_shift($fullVersionFragments); 73 | $minVersion = static::DB_MIN_SERVER_VERSION; 74 | 75 | // Detect MariaDB 76 | if (in_array('MariaDB', $fullVersionFragments)) { 77 | // Detect MySQL compat prefix (replication version hack) if present as first fragment 78 | // Note: Not present when using `SELECT VERSION()` query 79 | if ($currentVersion === '5.5.5') { 80 | $currentVersion = array_shift($fullVersionFragments); 81 | } 82 | 83 | $minVersion = static::DB_MIN_SERVER_VERSION_MARIADB; 84 | } 85 | 86 | static::assertIsDbVersionSupported( 87 | $currentVersion, 88 | $minVersion 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lib/MongoSql/Driver/PgsqlDriver.php: -------------------------------------------------------------------------------- 1 | connection->getAttribute(PDO::ATTR_SERVER_VERSION), 53 | static::DB_MIN_SERVER_VERSION 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/MongoSql/DriverException.php: -------------------------------------------------------------------------------- 1 | qv($mysqlPath)); 36 | 37 | // MySQL 5.7.9 38 | // return sprintf('`document` ->> %s', $this->qv($mysqlPath)); 39 | } 40 | 41 | /** 42 | * @inheritdoc 43 | */ 44 | protected function buildWhereSegment(string $func, string $fieldName, $value): ?string 45 | { 46 | $pathSelector = $this->createPathSelector($fieldName); 47 | 48 | switch ($func) { 49 | case '$eq': 50 | return vsprintf('%s = %s', [ 51 | $pathSelector, 52 | $this->qv($value), 53 | ]); 54 | 55 | case '$ne': 56 | return vsprintf('%s <> %s', [ 57 | $pathSelector, 58 | $this->qv($value), 59 | ]); 60 | 61 | case '$gte': 62 | return vsprintf('%s >= %s', [ 63 | $pathSelector, 64 | $this->qv($value), 65 | ]); 66 | 67 | case '$gt': 68 | return vsprintf('%s > %s', [ 69 | $pathSelector, 70 | $this->qv($value), 71 | ]); 72 | 73 | case '$lte': 74 | return vsprintf('%s <= %s', [ 75 | $pathSelector, 76 | $this->qv($value), 77 | ]); 78 | 79 | case '$lt': 80 | return vsprintf('%s < %s', [ 81 | $pathSelector, 82 | $this->qv($value), 83 | ]); 84 | 85 | // When db value is an array, this evaluates to false 86 | // Could use JSON_OVERLAPS but it's MySQL 8+ 87 | case '$in': 88 | return vsprintf('%s IN (%s)', [ 89 | $pathSelector, 90 | $this->qvs($value), 91 | ]); 92 | 93 | case '$nin': 94 | return vsprintf('%s NOT IN (%s)', [ 95 | $pathSelector, 96 | $this->qvs($value), 97 | ]); 98 | 99 | case '$has': 100 | if (!is_string($value)) { 101 | throw new InvalidArgumentException('Invalid argument for $has array not supported'); 102 | } 103 | 104 | return vsprintf('JSON_CONTAINS(%s, JSON_QUOTE(%s))', [ 105 | $pathSelector, 106 | $this->qv($value), 107 | ]); 108 | 109 | case '$all': 110 | if (!is_array($value)) { 111 | throw new InvalidArgumentException('Invalid argument for $all option must be array'); 112 | } 113 | 114 | return vsprintf('JSON_CONTAINS(%s, JSON_ARRAY(%s))', [ 115 | $pathSelector, 116 | $this->qvs($value), 117 | ]); 118 | 119 | // Note cockpit default is case sensitive 120 | // Note: ^ doesn't work 121 | case '$preg': 122 | case '$match': 123 | case '$regex': 124 | return vsprintf('LOWER(%s) REGEXP LOWER(%s)', [ 125 | $pathSelector, 126 | // Escape \ and trim / 127 | $this->qv(trim(str_replace('\\', '\\\\', $value), '/')), 128 | ]); 129 | 130 | case '$size': 131 | return vsprintf('JSON_LENGTH(%s) = %s', [ 132 | $pathSelector, 133 | $this->qv($value), 134 | ]); 135 | 136 | case '$mod': 137 | if (!is_array($value)) { 138 | throw new InvalidArgumentException('Invalid argument for $mod option must be array'); 139 | } 140 | 141 | return vsprintf('MOD(%s, %s) = %d', [ 142 | $pathSelector, 143 | // Remainder 144 | $this->qv($value[0]), 145 | // Divisor 146 | $this->qv($value[1] ?? 0), 147 | ]); 148 | 149 | case '$func': 150 | case '$fn': 151 | case '$f': 152 | throw new InvalidArgumentException(sprintf('Function %s not supported by database driver', $func), 1); 153 | 154 | // Warning: doesn't check if key exists 155 | case '$exists': 156 | return vsprintf('%s %s NULL', [ 157 | $pathSelector, 158 | $value ? 'IS NOT' : 'IS' 159 | ]); 160 | 161 | // Note: no idea how to implement. SOUNDEX doesn't search in strings. 162 | case '$fuzzy': 163 | throw new InvalidArgumentException(sprintf('Function %s not supported by database driver', $func), 1); 164 | 165 | case '$text': 166 | if (is_array($value)) { 167 | throw new InvalidArgumentException(sprintf('Options for %s function are not suppored by database driver', $func), 1); 168 | } 169 | 170 | return vsprintf('%s LIKE %s', [ 171 | $pathSelector, 172 | $this->qv(static::wrapLikeValue($value)) 173 | ]); 174 | 175 | // Skip Mongo specific stuff 176 | case '$options': 177 | break; 178 | 179 | default: 180 | throw new ErrorException(sprintf('Condition not valid ... Use %s for custom operations', $func)); 181 | } 182 | 183 | return null; 184 | } 185 | 186 | /** 187 | * @inheritdoc 188 | */ 189 | public function buildTableExists(string $tableName): string 190 | { 191 | return sprintf("SHOW TABLES LIKE '%s'", $tableName); 192 | } 193 | 194 | /** 195 | * @inheritdoc 196 | */ 197 | public function buildCreateTable(string $tableName): string 198 | { 199 | return <<qi($tableName)} ( 202 | "id" INT NOT NULL AUTO_INCREMENT, 203 | "document" JSON NOT NULL, 204 | -- Add generated column with unique key 205 | "_id_virtual" VARCHAR(24) GENERATED ALWAYS AS ({$this->createPathSelector('_id')}) UNIQUE COMMENT 'Id', 206 | PRIMARY KEY ("id"), 207 | CONSTRAINT "_id_virtual_not_null" CHECK ("_id_virtual" IS NOT NULL) 208 | ) ENGINE=InnoDB COLLATE 'utf8mb4_unicode_ci'; 209 | SQL; 210 | } 211 | 212 | /** 213 | * @inheritdoc 214 | */ 215 | public function qi(string $identifier): string 216 | { 217 | return sprintf('`%s`', str_replace('`', '``', $identifier)); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /lib/MongoSql/QueryBuilder/PgsqlQueryBuilder.php: -------------------------------------------------------------------------------- 1 | >' : '#>', 29 | $this->qv($pgsqlPath) 30 | ]); 31 | } 32 | 33 | /** 34 | * @inheritdoc 35 | * 36 | * Use undocumented aliases if need to avoid using question marks https://github.com/yiisoft/yii2/issues/15873 37 | * ? | jsonb_exists 38 | * ?| | jsonb_exists_any 39 | * ?& | jsonb_exists_all 40 | */ 41 | protected function buildWhereSegment(string $func, string $fieldName, $value): ?string 42 | { 43 | $pathTextSelector = $this->createPathSelector($fieldName); 44 | $pathObjectSelector = $this->createPathSelector($fieldName, false); 45 | 46 | // See https://stackoverflow.com/questions/19422640/how-to-query-for-null-values-in-json-field-type-postgresql 47 | if ($value === null) { 48 | $pathTextSelector = sprintf('(%s)::text', $pathObjectSelector); 49 | } 50 | 51 | switch ($func) { 52 | case '$eq': 53 | return vsprintf('%s = %s', [ 54 | $pathTextSelector, 55 | $this->qv($value), 56 | ]); 57 | 58 | case '$ne': 59 | return vsprintf('%s <> %s', [ 60 | $pathTextSelector, 61 | $this->qv($value), 62 | ]); 63 | 64 | case '$gte': 65 | return vsprintf('%s >= %s', [ 66 | $pathTextSelector, 67 | $this->qv($value), 68 | ]); 69 | 70 | case '$gt': 71 | return vsprintf('%s > %s', [ 72 | $pathTextSelector, 73 | $this->qv($value), 74 | ]); 75 | 76 | case '$lte': 77 | return vsprintf('%s <= %s', [ 78 | $pathTextSelector, 79 | $this->qv($value), 80 | ]); 81 | 82 | case '$lt': 83 | return vsprintf('%s < %s', [ 84 | $pathTextSelector, 85 | $this->qv($value), 86 | ]); 87 | 88 | case '$in': 89 | return vsprintf('%s IN (%s)', [ 90 | $pathTextSelector, 91 | $this->qvs($value), 92 | ]); 93 | 94 | case '$nin': 95 | return vsprintf('%s NOT IN (%s)', [ 96 | $pathTextSelector, 97 | $this->qvs($value), 98 | ]); 99 | 100 | // Warning: When using PDO, make sure it handles question mark properly 101 | case '$has': 102 | return vsprintf('%s ? %s', [ 103 | // return vsprintf('jsonb_exists(%s, %s)', [ 104 | $pathObjectSelector, 105 | $this->qv($value) 106 | ]); 107 | 108 | case '$all': 109 | return vsprintf('%s ?& array[%s]', [ 110 | // return vsprintf('jsonb_exists_all(%s, array[%s])', [ 111 | $pathObjectSelector, 112 | $this->qvs($value), 113 | ]); 114 | 115 | // Note: cockpit default is case sensitive 116 | // See https://www.postgresql.org/docs/9.3/functions-matching.html#FUNCTIONS-POSIX-REGEXP 117 | case '$preg': 118 | case '$match': 119 | case '$regex': 120 | return vsprintf('%s ~* %s', [ 121 | $pathTextSelector, 122 | $this->qv(trim($value, '/')), 123 | ]); 124 | 125 | case '$size': 126 | return vsprintf('jsonb_array_length(%s) = %s', [ 127 | $pathObjectSelector, 128 | $this->qv($value) 129 | ]); 130 | 131 | // See https://www.postgresql.org/docs/7.4/functions-math.html 132 | case '$mod': 133 | if (!is_array($value)) { 134 | throw new InvalidArgumentException('Invalid argument for $mod option must be array'); 135 | } 136 | 137 | return vsprintf('(%s)::int %% %s = %s', [ 138 | $pathTextSelector, 139 | // Divisor 140 | $this->qv($value[0]), 141 | // Remainder 142 | $this->qv($value[1] ?? 0), 143 | ]); 144 | 145 | case '$func': 146 | case '$fn': 147 | case '$f': 148 | throw new InvalidArgumentException(sprintf('Function %s not supported by database driver', $func), 1); 149 | 150 | // Warning: doesn't check if key exists 151 | case '$exists': 152 | return vsprintf('%s %s NULL', [ 153 | $pathTextSelector, 154 | $value ? 'IS NOT' : 'IS', 155 | ]); 156 | 157 | // Note: no idea how to implement. 158 | case '$fuzzy': 159 | throw new InvalidArgumentException(sprintf('Function %s not supported by database driver', $func), 1); 160 | 161 | case '$text': 162 | if (is_array($value)) { 163 | throw new InvalidArgumentException(sprintf('Options for %s function are not suppored by database driver', $func), 1); 164 | } 165 | 166 | return vsprintf('(%s)::text LIKE %s', [ 167 | $pathTextSelector, 168 | $this->qv(static::wrapLikeValue($value)) 169 | ]); 170 | 171 | // Skip Mongo specific stuff 172 | case '$options': 173 | break; 174 | 175 | default: 176 | throw new ErrorException(sprintf('Condition not valid ... Use %s for custom operations', $func)); 177 | } 178 | 179 | return null; 180 | } 181 | 182 | /** 183 | * @inheritdoc 184 | */ 185 | public function buildTableExists(string $tableName): string 186 | { 187 | return sprintf("SELECT to_regclass(%s)", $this->qv($tableName)); 188 | } 189 | 190 | /** 191 | * @inheritdoc 192 | */ 193 | public function buildCreateTable(string $tableName): string 194 | { 195 | return <<qi($tableName)} ( 198 | "id" serial NOT NULL, 199 | "document" jsonb NOT NULL, 200 | -- Generated columns requires PostgreSQL 12+ 201 | -- "_id_virtual" VARCHAR(24) GENERATED ALWAYS AS ("document" #> '_id') STORED, 202 | PRIMARY KEY ("id") 203 | ); 204 | 205 | -- Add index to _id (Pg 9.5+) 206 | -- Cannot add index on JSON table in CREATE TABLE statement (expressions not supported) 207 | CREATE UNIQUE INDEX IF NOT EXISTS {$this->qi('idx_' . $tableName . '_id')} ON {$this->qi($tableName)} ( 208 | (("document" ->> '_id')::text) 209 | ); 210 | SQL; 211 | } 212 | 213 | /** 214 | * @inheritdoc 215 | */ 216 | public function qi(string $identifier): string 217 | { 218 | return sprintf('"%s"', str_replace('"', '\"', $identifier)); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /lib/MongoSql/QueryBuilder/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | self::GLUE_OPERATOR_AND, 23 | '$or' => self::GLUE_OPERATOR_OR, 24 | ]; 25 | 26 | /** @var callable */ 27 | protected $connectionQuote; 28 | 29 | /** 30 | * Constructor 31 | * 32 | * @param callable $connectionQuote - Connection quote callable 33 | */ 34 | public function __construct(callable $connectionQuote) 35 | { 36 | $this->connectionQuote = $connectionQuote; 37 | } 38 | 39 | /** 40 | * Create query builder from connection 41 | * 42 | * @param \PDO 43 | * @return self 44 | */ 45 | public static function createFromPdo(PDO $connection): self 46 | { 47 | // Get driver name 48 | $pdoDriverName = $connection->getAttribute(PDO::ATTR_DRIVER_NAME); 49 | 50 | // Resolve FQCN 51 | $fqcn = sprintf('%s\\%sQueryBuilder', __NAMESPACE__, ucfirst($pdoDriverName)); 52 | 53 | if (!class_exists($fqcn)) { 54 | throw new \RuntimeException('Cannot initialize query builder for %s driver', $pdoDriverName); 55 | } 56 | 57 | return new $fqcn([$connection, 'quote']); 58 | } 59 | 60 | /** 61 | * Create path selector for field 62 | * Nested fields are separater by comma 63 | * 64 | * @param string $fieldName 65 | * @return string 66 | */ 67 | abstract public function createPathSelector(string $fieldName): string; 68 | 69 | /** 70 | * Split JSON path by dot end wrap non-numeric segments in double quotes. 71 | * Doesn't support dot inside field name as MongoDB doesn't either 72 | * To do so, see {@link https://stackoverflow.com/questions/2202435/} 73 | * @param string $path 74 | * @return array 75 | */ 76 | protected static function splitPath(string $path): array 77 | { 78 | $segments = explode('.', $path); 79 | 80 | return array_map(function (string $segment): string { 81 | return is_numeric($segment) 82 | ? $segment 83 | : sprintf('"%s"', str_replace('"', '\\"', $segment)); 84 | }, $segments); 85 | } 86 | 87 | /** 88 | * Build ORDER BY subquery 89 | * 90 | * @param array [$sorts] 91 | * @return string|null 92 | */ 93 | public function buildOrderby(array $sorts = null): ?string 94 | { 95 | if (!$sorts) { 96 | return null; 97 | } 98 | 99 | $sqlOrderBySegments = []; 100 | 101 | foreach ($sorts as $fieldName => $direction) { 102 | $sqlOrderBySegments[] = vsprintf('%s %s', [ 103 | $this->createPathSelector($fieldName), 104 | $direction === static::ORDER_BY_DESC ? 'DESC' : 'ASC' 105 | ]); 106 | } 107 | 108 | return !empty($sqlOrderBySegments) 109 | ? 'ORDER BY ' . implode(', ', $sqlOrderBySegments) 110 | : null; 111 | } 112 | 113 | /** 114 | * Build LIMIT subquery 115 | * 116 | * @param int [$limit] 117 | * @param int [$offset] 118 | * @return string|null 119 | */ 120 | public function buildLimit(int $limit = null, int $offset = null): ?string 121 | { 122 | if (!$limit) { 123 | return null; 124 | } 125 | 126 | // Offset (limit must be provided) 127 | // See https://stackoverflow.com/questions/255517/mysql-offset-infinite-rows 128 | if ($offset) { 129 | return sprintf('LIMIT %d OFFSET %d', $limit, $offset); 130 | } 131 | 132 | return sprintf('LIMIT %d', $limit); 133 | } 134 | 135 | /** 136 | * Build WHERE subquery 137 | * 138 | * @param array [$criteria] 139 | * @return string|null 140 | */ 141 | public function buildWhere(array $criteria = null): ?string 142 | { 143 | if (!$criteria) { 144 | return null; 145 | } 146 | 147 | $segments = $this->buildWhereSegments($criteria); 148 | 149 | return !empty($segments) 150 | ? sprintf('WHERE %s', $segments) 151 | : null; 152 | } 153 | 154 | /** 155 | * Build WHERE segments 156 | * 157 | * @see \MongoLite\Database\UtilArrayQuery::buildCondition 158 | * 159 | * @param array $criteria 160 | * @param string [$concat] 161 | * @return string|null 162 | */ 163 | protected function buildWhereSegments(array $criteria, string $concat = self::GLUE_OPERATOR_AND): ?string 164 | { 165 | $whereSegments = []; 166 | 167 | // Key may be field name or operator 168 | foreach ($criteria as $key => $value) { 169 | switch ($key) { 170 | // Operators: value is array of conditions 171 | case '$and': 172 | case '$or': 173 | $whereSubSegments = []; 174 | 175 | foreach ($value as $subCriteria) { 176 | $whereSubSegments[] = $this->buildWhereSegments($subCriteria, static::GLUE_OPERATOR[$key]); 177 | } 178 | 179 | $whereSegments[] = '(' . implode(static::GLUE_OPERATOR[$key], $whereSubSegments) . ')'; 180 | break; 181 | 182 | // No operator: 183 | default: 184 | // $not operator in values' key, condition in it's value 185 | if (is_array($value) && array_keys($value) === ['$not']) { 186 | $whereSegments[] = 'NOT ' . is_array($value['$not']) 187 | ? $this->buildWhereSegments([$key => $value['$not']]) 188 | : $this->buildWhereSegmentsGroup((string) $key, ['$regex' => $value['$not']]); 189 | break; 190 | } 191 | 192 | // Value 193 | $whereSegments[] = $this->buildWhereSegmentsGroup( 194 | (string) $key, 195 | is_array($value) ? $value : ['$eq' => $value] 196 | ); 197 | break; 198 | } 199 | } 200 | 201 | if (empty($whereSegments)) { 202 | return null; 203 | } 204 | 205 | return implode($concat, $whereSegments); 206 | } 207 | 208 | /** 209 | * Build where segments group 210 | * 211 | * @see \MongoLite\Database\UtilArrayQuery::check 212 | * 213 | * @param string $fieldName 214 | * @param array $conditions 215 | * @return string 216 | */ 217 | protected function buildWhereSegmentsGroup(string $fieldName, array $conditions): string 218 | { 219 | $subSegments = []; 220 | 221 | foreach ($conditions as $func => $value) { 222 | $subSegments[] = $this->buildWhereSegment($func, $fieldName, $value); 223 | // TODO: pass path to builWhereSegment 224 | // $subSegments[] = $this->buildWhereSegment($func, $this->createPathSelector($fieldName), $value); 225 | } 226 | 227 | // Remove nulls 228 | $subSegments = array_filter($subSegments); 229 | 230 | return implode(static::GLUE_OPERATOR_AND, $subSegments); 231 | } 232 | 233 | /** 234 | * Build single where segment 235 | * Should implement: 236 | * - $eq (equals), $ne (not eqals), $gte (greater or equals), $gt (greater), $lte (lower or equals), $lt (lower), 237 | * - $in (target is one of array elements), $nin (target is not one of array elements), $has (target contains array elements), $all (target contains all array elements), 238 | * - $regex (Regex) 239 | * - $size (Array size), 240 | * - $mod (Mod), 241 | * - $func (Callback), 242 | * - $fuzzy (Fuzzy search), $text (Search in text) 243 | * - $options 244 | * or throw \InvalidArgumentException when func is not implemented 245 | * 246 | * @see \MongoLite\Database\UtilArrayQuery::evaluate 247 | * 248 | * @param string $func 249 | * @param string $fieldName 250 | * @param mixed $value 251 | * @return string|null 252 | * @throws \InvalidArgumentException 253 | * @throws \ErrorException 254 | */ 255 | abstract protected function buildWhereSegment(string $func, string $fieldName, $value): ?string; 256 | 257 | /** 258 | * Build query checking if table exists 259 | * NOT USED 260 | * 261 | * @param string $tableName 262 | * @return string 263 | */ 264 | abstract public function buildTableExists(string $tableName): string; 265 | 266 | /** 267 | * Build query to create table 268 | * 269 | * @param string $tableName 270 | * @return string 271 | */ 272 | abstract public function buildCreateTable(string $tableName): string; 273 | 274 | /** 275 | * Quote value 276 | * @param mixed $value 277 | * @return string 278 | * @throws \TypeError - When passing non-string to quote: PDO::quote() expects parameter 1 to be string, xxx given 279 | */ 280 | public function qv($value): string 281 | { 282 | if (!is_string($value)) { 283 | $value = static::jsonEncode($value); 284 | } 285 | 286 | return ($this->connectionQuote)((string) $value); 287 | } 288 | 289 | /** 290 | * Quote multiple values 291 | * @param array $values 292 | * @return string 293 | */ 294 | public function qvs(array $values): string 295 | { 296 | return implode(', ', array_map([$this, 'qv'], $values)); 297 | } 298 | 299 | /** 300 | * Quote identifier (table or column name) 301 | * 302 | * @param string $identifier 303 | * @return string 304 | */ 305 | abstract public function qi(string $identifier): string; 306 | 307 | /** 308 | * Quote and escape LIKE value 309 | * 310 | * @param string $value 311 | * @return string 312 | */ 313 | public static function wrapLikeValue(string $value): string 314 | { 315 | return sprintf('%%%s%%', strtr($value, [ 316 | '_' => '\\_', 317 | '%' => '\\%', 318 | ])); 319 | } 320 | 321 | /** 322 | * Encode value helper 323 | * 324 | * @param mixed $value 325 | * @return string 326 | */ 327 | public static function jsonEncode($value): string 328 | { 329 | // Slashes are nomalized by MySQL anyway 330 | return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 331 | } 332 | 333 | /** 334 | * Decode value helper 335 | * 336 | * @param string $string 337 | * @return mixed 338 | */ 339 | public static function jsonDecode(string $string) 340 | { 341 | return json_decode($string, true); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /lib/MongoSql/ResultIterator.php: -------------------------------------------------------------------------------- 1 | driver = $driver; 43 | } 44 | 45 | /** 46 | * @inheritdoc 47 | * [NOT TESTED] 48 | */ 49 | public function hasOne(iterable $collections): ResultInterface 50 | { 51 | $this->hasOne[] = $collections; 52 | 53 | foreach ($collections as $fkey => $collection) { 54 | $this->hasOneCache[$collection] = []; 55 | } 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * Apply hasOne relationship to document 62 | * 63 | * @param array &$doc 64 | * @return self 65 | */ 66 | protected function applyHasOne(array &$doc): self 67 | { 68 | // Apply hasOne 69 | foreach ($this->hasOne as $collections) { 70 | foreach ($collections as $fkey => $collection) { 71 | if (!empty($doc[$fkey])) { 72 | $docFkey = $doc[$fkey]; 73 | 74 | if (!isset($this->hasOneCache[$collection][$docFkey])) { 75 | $this->hasOneCache[$collection][$docFkey] = $this->driver->findOneById($collection, $docFkey); 76 | } 77 | 78 | $doc[$fkey] = $this->hasOneCache[$collection][$docFkey]; 79 | } 80 | } 81 | } 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * @inheritdoc 88 | * [NOT TESTED] 89 | */ 90 | public function hasMany(iterable $collections): ResultInterface 91 | { 92 | $this->hasMany[] = $collections; 93 | 94 | foreach ($collections as $collection => $fkey) { 95 | $this->hasManyCache[$collection] = []; 96 | } 97 | 98 | return $this; 99 | } 100 | 101 | /** 102 | * Apply hasMany relationship to document 103 | * 104 | * @param array &$doc 105 | * @return self 106 | */ 107 | public function applyHasMany(array &$doc): self 108 | { 109 | // Apply hasMany 110 | if (!empty($doc['_id'])) { 111 | foreach ($this->hasMany as $collections) { 112 | foreach ($collections as $collection => $fkey) { 113 | if (!isset($this->hasManyCache[$collection][$fkey])) { 114 | $this->hasManyCache[$collection] = $this->driver->find($collection, [ 115 | 'filter' => [$fkey => $doc['_id']] 116 | ]); 117 | } 118 | 119 | $doc[$collection] = $this->hasManyCache[$collection][$fkey]; 120 | } 121 | } 122 | } 123 | 124 | return $this; 125 | } 126 | 127 | /** 128 | * @inheritdoc 129 | */ 130 | public function current() 131 | { 132 | // Use parent with IteratorAggregate 133 | $doc = parent::current(); 134 | 135 | if ($doc !== null) { 136 | $this 137 | ->applyHasOne($doc) 138 | ->applyHasMany($doc) 139 | ; 140 | } 141 | 142 | return $doc; 143 | } 144 | 145 | /** 146 | * @inheritdoc 147 | */ 148 | public function toArray(): array 149 | { 150 | return iterator_to_array($this->getInnerIterator()); 151 | } 152 | } 153 | --------------------------------------------------------------------------------