From 3c8b7cd5957ebe60f5547c25973593cf7a6c0add Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 9 Jan 2021 11:50:55 -0300 Subject: [PATCH 01/24] Add tests for PHP 7.4 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index a856367..c97e95a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ php: - 7.1 - 7.2 - 7.3 + - 7.4 services: mysql From af651d335132a114eee46b9c144414c6c4e981b4 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 16 Jan 2021 10:33:29 -0300 Subject: [PATCH 02/24] Fix CrudTrait fields parameter --- src/DB/CrudTrait.php | 6 ++++-- test/CrudTest.php | 29 +++++++++++++++++++++++------ test/TableFromClassTest.php | 2 +- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/DB/CrudTrait.php b/src/DB/CrudTrait.php index 2146011..0eb158d 100644 --- a/src/DB/CrudTrait.php +++ b/src/DB/CrudTrait.php @@ -68,10 +68,12 @@ public function index($params = [], $filter = []) $queryparams = []; if (!empty($params['fields'])) { if (!is_array($params['fields'])) { - $queryparams['fields'] = explode(',', $params['fields']); + $params['fields'] = explode(',', $params['fields']); } - $queryparams['fields'][0] = 'SQL_CALC_FOUND_ROWS ' . $params['fields'][0]; + $params['fields'][0] = 'SQL_CALC_FOUND_ROWS ' . $params['fields'][0]; + + $queryparams['fields'] = $params['fields']; } else { $queryparams['fields'] = "SQL_CALC_FOUND_ROWS $this->table.*"; diff --git a/test/CrudTest.php b/test/CrudTest.php index 723a704..87227d7 100644 --- a/test/CrudTest.php +++ b/test/CrudTest.php @@ -12,11 +12,11 @@ class CrudTest extends PHPUnit_Framework_TestCase /** @var Table */ static protected $table; static protected $testData = [ - 1 => ['name' => 'test'], - 2 => ['name' => 'test1'], - 3 => ['name' => 'test2'], - 4 => ['name' => 'test3'], - 5 => ['name' => null] + 1 => ['name' => 'test', 'f1' => 'test', 'f2' => 'test', 'f3' => 'test'], + 2 => ['name' => 'test1', 'f1' => 'test1', 'f2' => 'test1', 'f3' => 'test1'], + 3 => ['name' => 'test2', 'f1' => 'test2', 'f2' => 'test2', 'f3' => 'test2'], + 4 => ['name' => 'test3', 'f1' => 'test3', 'f2' => 'test3', 'f3' => 'test3'], + 5 => ['name' => null, 'f1' => 'test3', 'f2' => 'test3', 'f3' => 'test3'] ]; public static function setUpBeforeClass() @@ -26,7 +26,7 @@ public static function setUpBeforeClass() $db->query('create table db_test (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255));')->exec(); + `name` VARCHAR(255), `f1` VARCHAR(255), `f2` VARCHAR(255), `f3` VARCHAR(255));')->exec(); self::$table = $db->table('db_test'); @@ -64,6 +64,19 @@ public function testIndex() $this->assertEquals(5, $count); } + /** + * @depends testInsert + */ + public function testFields() { + $data = self:: $table->index(['fields' => ['id', 'f1', 'f2']]); + + foreach($data as $result) { + $this->assertCount(3, $result); + $this->assertEquals($result['f1'], self::$testData[$result['id']]['f1']); + $this->assertEquals($result['f2'], self::$testData[$result['id']]['f2']); + } + } + /** * @depends testInsert */ @@ -145,6 +158,9 @@ public function testSelectKey() $this->assertEquals('test2.1', $r['name']); } + /** + * @depends testSelectKey + */ public function testDelete() { $data = self::$table->index(); @@ -159,4 +175,5 @@ public function testDelete() $data = self::$table->index(); $this->assertEquals($data->total(), 4); } + } diff --git a/test/TableFromClassTest.php b/test/TableFromClassTest.php index 5542c57..2f6915e 100644 --- a/test/TableFromClassTest.php +++ b/test/TableFromClassTest.php @@ -25,7 +25,7 @@ public static function setUpBeforeClass() $db->query('create table db_test (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255));')->exec(); + `name` VARCHAR(255), `f1` VARCHAR(255), `f2` VARCHAR(255), `f3` VARCHAR(255));')->exec(); self::$table = $db->table('DbTestTable'); } From 3563d8d1b0bca904b14de985510988ce118b921b Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Mon, 18 Jan 2021 21:19:41 -0300 Subject: [PATCH 03/24] Return object references from Collection --- src/DB/Collection.php | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/DB/Collection.php b/src/DB/Collection.php index d648d06..c18adac 100644 --- a/src/DB/Collection.php +++ b/src/DB/Collection.php @@ -52,7 +52,7 @@ public function offsetExists($offset) return array_key_exists($offset, $this->data); } - public function offsetGet($offset) + public function &offsetGet($offset) { return $this->data[$offset]; } @@ -72,14 +72,10 @@ public function count() return count($this->data); } - public function getIterator() + public function &getIterator() { - $dataIterator = function () { - foreach($this->data as $key => $val) { - yield $key => $val; - } - }; - - return $dataIterator(); + foreach($this->data as $key => &$val) { + yield $key => $val; + } } } From 63814b0a9fbf82595ce85aa987155814460d3208 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 23 May 2021 11:12:46 -0300 Subject: [PATCH 04/24] Convert boolean values to ints --- src/DB.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/DB.php b/src/DB.php index 5bf394a..d351f73 100644 --- a/src/DB.php +++ b/src/DB.php @@ -121,7 +121,7 @@ function transaction($callable) try { $ret = call_user_func($callable, $this); - if(!$this->commit()) { + if (!$this->commit()) { throw new \Exception('Cannot commit transaction', 500); } @@ -268,7 +268,7 @@ function insert($table, $data) $query = $this->query($sql); foreach ($fields as $field) { - $query->bind($field, $data[$field]); + $query->bind($field, is_bool($data[$field]) ? intval($data[$field]) : $data[$field]); } $rows = $query->exec(); @@ -295,7 +295,7 @@ function update($table, $data, $where = null) foreach ($data as $key => $value) { $changes[] = "$key = :update_$key"; - $bindings[":update_$key"] = $value; + $bindings[":update_$key"] = is_bool($value) ? intval($value) : $value; } if (empty($changes)) { From a856e9f9bdbd0a287a28a82924fef3b3252e0804 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 23 May 2021 11:12:59 -0300 Subject: [PATCH 05/24] Log queries when debug is on --- src/DB.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/DB.php b/src/DB.php index d351f73..4366962 100644 --- a/src/DB.php +++ b/src/DB.php @@ -89,6 +89,7 @@ function query($sql) if ($this->debug) { $query->sql = $sql; + error_log($sql); } return $query; From fa31d3f879d681021f4a5e5a5397606d20df227f Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 23 May 2021 11:13:13 -0300 Subject: [PATCH 06/24] Add documentation for JOINs --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index d122686..7294baa 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,16 @@ Getting Started // select IN $rows = $db->select('table', array('ID' => array( 1, 2, 3))->all(); + // select JOIN + $rows = $db->select('table', [], ['join' => [ + 'othertable o on o.some_id = table.id', // full join in single line + 'table1' => 'table1.some_id = table.id', // inner join table1 table1 on table1.some_id = table.id + '*table1' => 'table1.some_id = table.id' // left join table1 table1 on ... + ]); + + // select JOIN with raw query + $rows = $db->select('table', [], ['join' => 'othertable b on b.x = table.id']); + // fetch row by row $query = $db->select('table'); From 2029b20fe044d6ee592a8868fd45fe8fab26c034 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 17 May 2025 20:34:31 -0300 Subject: [PATCH 07/24] update: add $limit support --- src/DB.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/DB.php b/src/DB.php index 4366962..dea37ac 100644 --- a/src/DB.php +++ b/src/DB.php @@ -229,7 +229,7 @@ function select($table, $where = null, $params = array()) if ($params['group']) { if (is_array($params['group'])) { - throw new \Exception('not implemented'); + throw new \Exception('not implemented, use a single field (string)'); } else { $sql .= sprintf(' GROUP BY %s', $params['group']); } @@ -287,7 +287,7 @@ function insert($table, $data) * @return int number of updated rows * @throws \Exception */ - function update($table, $data, $where = null) + function update($table, $data, $where = null, $limit = null) { $changes = array(); @@ -304,10 +304,11 @@ function update($table, $data, $where = null) } $sql = sprintf(/** @lang text */ - "UPDATE `%s` SET %s WHERE %s", + "UPDATE `%s` SET %s WHERE %s %s", $this->prefix . $table, implode(", ", $changes), - $where); + $where, + $limit ? "LIMIT $limit" : ""); $query = $this->query($sql); From 06cee755d1113aa249e5aaa8bd3743cde35cef1a Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 17 May 2025 20:34:53 -0300 Subject: [PATCH 08/24] feat: add NOT support in where conditions --- src/DB.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/DB.php b/src/DB.php index dea37ac..8e2f19a 100644 --- a/src/DB.php +++ b/src/DB.php @@ -349,6 +349,13 @@ private function _where($args = null, $glue = "AND") // TODO suportar _and, _or foreach ($args as $key => $value) { + if ($key[0] == '!') { + $key = substr($key, 1); + $not = true; + } else { + $not = false; + } + // TODO if is_numeric($key) $table = str_replace('`', '``', $key); $table = implode('`.`', explode(".", $table)); @@ -360,7 +367,11 @@ private function _where($args = null, $glue = "AND") } else { $cond[] = sprintf("`%s` %s :where_%s", $table, - is_null($value) ? 'is' : (strpos($value, '%') !== FALSE ? 'LIKE' : '='), + is_null($value) ? + ($not ? 'IS NOT' : 'IS') : + (strpos($value, '%') !== FALSE ? // TODO allow escaping % + ($not ? 'NOT LIKE' : 'LIKE') : + ($not ? '<>' : '=')), $key); $bindings[":where_$key"] = $value; } From 072a49a890d5ce0ef42b460b34c5ee58f97ff6eb Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 17 May 2025 20:36:04 -0300 Subject: [PATCH 09/24] fix: add main table reference to default primary key --- src/DB/CrudTrait.php | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/DB/CrudTrait.php b/src/DB/CrudTrait.php index 0eb158d..f331036 100644 --- a/src/DB/CrudTrait.php +++ b/src/DB/CrudTrait.php @@ -33,10 +33,14 @@ protected function crudSetup(DB $db, $table, $params = []) if (!$this->params) { $this->params = array_merge([ 'pk' => 'id', - 'join' => [] + 'join' => [], + 'fields' => ["$this->table.*"] ], $params); } + + $this->params['pk'] = implode('`.`', explode('.', $this->params['pk'])); + } /** @@ -66,18 +70,17 @@ public function index($params = [], $filter = []) // Other query parameters $queryparams = []; - if (!empty($params['fields'])) { - if (!is_array($params['fields'])) { - $params['fields'] = explode(',', $params['fields']); - } + if (empty($params['fields'])) { + $params['fields'] = $this->params['fields']; + } - $params['fields'][0] = 'SQL_CALC_FOUND_ROWS ' . $params['fields'][0]; + if (!is_array($params['fields'])) { + $params['fields'] = explode(',', $params['fields']); + } - $queryparams['fields'] = $params['fields']; + $params['fields'][0] = 'SQL_CALC_FOUND_ROWS ' . $params['fields'][0]; - } else { - $queryparams['fields'] = "SQL_CALC_FOUND_ROWS $this->table.*"; - } + $queryparams['fields'] = $params['fields']; if (!empty($params['sort'])) { if (!is_array($params['sort'])) { @@ -100,6 +103,7 @@ public function index($params = [], $filter = []) } $queryparams['join'] = isset($params['join']) ? $params['join'] : $this->params['join']; + $queryparams['group'] = isset($params['group']) ? $params['group'] : $this->params['group']; $query = $this->db->select($this->table, $where, $queryparams); @@ -139,6 +143,8 @@ public function get($key = null, $params = []) } $params['join'] = $this->params['join']; + $params['fields'] = $this->params['fields']; + $params['group'] = $this->params['group']; // get single $key = sprintf('`%s` = %s', $this->params['pk'], $this->db->escape($key)); From a84430fe0cc29fe6187584516c6ecd2daf2b3761 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 24 May 2025 11:16:37 -0300 Subject: [PATCH 10/24] feat: allow joining the same table more than 1 time --- src/DB.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/DB.php b/src/DB.php index 8e2f19a..483fe1a 100644 --- a/src/DB.php +++ b/src/DB.php @@ -184,7 +184,18 @@ function select($table, $where = null, $params = array()) } else { $join .= ' inner'; } - $join .= " join {$this->prefix}{$k} {$k} on {$v}"; + + $join_table = explode(" ", trim($k)); + + if (count($join_table) > 1) { + $join_alias = $join_table[1]; + } else { + $join_alias = $join_table[0]; + } + + $join_table = $this->prefix . $join_table[0]; + + $join .= " join {$join_table} {$join_alias} on {$v}"; } } } else { From 27d2f84b5a874d5aaba3dc982090a85330fcf4fa Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Mon, 29 Dec 2025 17:47:11 -0300 Subject: [PATCH 11/24] CrudTrait refactoring/improvements - Use standard names for functions (insert, select) - Reuse parameter parser in get and select --- src/DB/CrudTrait.php | 73 ++++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/src/DB/CrudTrait.php b/src/DB/CrudTrait.php index f331036..b22ea27 100644 --- a/src/DB/CrudTrait.php +++ b/src/DB/CrudTrait.php @@ -43,31 +43,13 @@ protected function crudSetup(DB $db, $table, $params = []) } - /** - * index($query = array()) - * Returns a list of rows matching $query. - * - * @param array $params - * You can define - * $params[range] range of records to return - * $params[sort] the results sort order - * @param array $filter Default filter that is merged with params, overrides any user-provided values - * @return array - * @throws \Exception - */ public function index($params = [], $filter = []) { - // Where clause - if (!empty($params['filter'])) { - if (!is_array($params['filter'])) { - $where = array_merge(json_decode($params['filter'], true), $filter); - } else { - $where = array_merge($params['filter'], $filter); - } - } else { - $where = $filter; - } + return $this->select($filter, $params); + } + private function parse_params($params) + { // Other query parameters $queryparams = []; if (empty($params['fields'])) { @@ -78,7 +60,8 @@ public function index($params = [], $filter = []) $params['fields'] = explode(',', $params['fields']); } - $params['fields'][0] = 'SQL_CALC_FOUND_ROWS ' . $params['fields'][0]; + $first_field = array_key_first($params['fields']); + $params['fields'][$first_field] = 'SQL_CALC_FOUND_ROWS ' . $params['fields'][$first_field]; $queryparams['fields'] = $params['fields']; @@ -105,6 +88,36 @@ public function index($params = [], $filter = []) $queryparams['join'] = isset($params['join']) ? $params['join'] : $this->params['join']; $queryparams['group'] = isset($params['group']) ? $params['group'] : $this->params['group']; + return $queryparams; + } + /** + * select(array $filter, array $params) + * Returns a list of rows matching $query. + * + * @param array $filter Default filter that is merged with params, overrides any user-provided values + * @param array $params + * You can define + * $params[range] range of records to return + * $params[sort] the results sort order + * @return Collection + * @throws \Exception + */ + + public function select(array $filter = [], array $params = []): Collection + { + // Where clause + if (!empty($params['filter'])) { + if (!is_array($params['filter'])) { + $where = array_merge(json_decode($params['filter'], true), $filter); + } else { + $where = array_merge($params['filter'], $filter); + } + } else { + $where = $filter; + } + + $queryparams = $this->parse_params($params); + $query = $this->db->select($this->table, $where, $queryparams); $rows_query = $this->db->query('SELECT FOUND_ROWS() as count'); @@ -137,14 +150,11 @@ public function index($params = [], $filter = []) */ public function get($key = null, $params = []) { - if (empty($key) || is_array($key)) { - return $this->index($key); + return $this->select($key, $params); } - $params['join'] = $this->params['join']; - $params['fields'] = $this->params['fields']; - $params['group'] = $this->params['group']; + $params = $this->parse_params($params); // get single $key = sprintf('`%s` = %s', $this->params['pk'], $this->db->escape($key)); @@ -157,6 +167,11 @@ public function get($key = null, $params = []) return $rsrc; } + public function insert(array $data): ?array + { + return $this->post($data); + } + public function post($data) { $id = $this->db->insert($this->table, $data); @@ -186,7 +201,7 @@ public function delete($key) public function findBy($key, $value) { - return $this->index([ + return $this->select([ 'filter' => [ $key => $value ] From e22a97861585c009b20a56bf94dfcf8dc3b9bf50 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Thu, 1 Jan 2026 18:53:05 -0300 Subject: [PATCH 12/24] DB: Fix query `order` param CrudTrait: pass sort as order, use explicit table.id key --- src/DB.php | 2 +- src/DB/CrudTrait.php | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/DB.php b/src/DB.php index 483fe1a..45f829d 100644 --- a/src/DB.php +++ b/src/DB.php @@ -248,7 +248,7 @@ function select($table, $where = null, $params = array()) if (!empty($params['order'])) { if (is_array($params['order'])) { - $params['order'] = implode(' ', $params['order']); + $params['order'] = implode(', ', $params['order']); } $sql .= sprintf(' ORDER BY %s', $params['order']); } diff --git a/src/DB/CrudTrait.php b/src/DB/CrudTrait.php index b22ea27..88f414d 100644 --- a/src/DB/CrudTrait.php +++ b/src/DB/CrudTrait.php @@ -66,13 +66,7 @@ private function parse_params($params) $queryparams['fields'] = $params['fields']; if (!empty($params['sort'])) { - if (!is_array($params['sort'])) { - $sort = json_decode($params['sort']); - } else { - $sort = $params['sort']; - } - - $queryparams['order'] = implode(" ", $sort); + $queryparams['order'] = $params['sort']; } if (!empty($params['range'])) { @@ -157,7 +151,7 @@ public function get($key = null, $params = []) $params = $this->parse_params($params); // get single - $key = sprintf('`%s` = %s', $this->params['pk'], $this->db->escape($key)); + $key = sprintf('`%s`.`%s` = %s', $this->table, $this->params['pk'], $this->db->escape($key)); $query = $this->db->select($this->table, $key, $params); if (!$rsrc = $query->fetch()) { From cb82fdccf566694bfe04632ea1d52c5679d047ff Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 15 Feb 2026 13:16:10 -0300 Subject: [PATCH 13/24] feat: add CI --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b902644 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.2', '8.3'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction + + - name: Run tests + run: vendor/bin/phpunit From e8052e7fc3fa86846d7bc4e75ca920ed7ab31646 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 14 Feb 2026 13:39:50 -0300 Subject: [PATCH 14/24] DBAL + Model support feat: use dbal, add pgsql/mysql tests feat: Add Model support feat: Add CI fix: update dependencies fix: remove CrudTrait, update Table methods to use sql naming --- README.md | 251 ++++-- composer.json | 5 +- docker-compose.yml | 50 ++ docker/php/Dockerfile | 17 + phpunit.xml | 24 + scripts/test-all-databases.sh | 16 + scripts/test-docker.sh | 9 + src/DB.php | 826 ++++++++++++------ src/DB/Collection.php | 73 +- src/DB/CrudTrait.php | 204 ----- src/DB/Exception/DBException.php | 9 + src/DB/Exception/InvalidQueryException.php | 9 + src/DB/Exception/ModelValidationException.php | 9 + src/DB/Exception/NotFoundException.php | 9 + src/DB/Exception/TransactionException.php | 9 + src/DB/Model.php | 265 ++++++ src/DB/Query.php | 159 ++-- src/DB/Table.php | 218 ++++- test/CrudTest.php | 142 ++- test/DBCoverageTest.php | 128 +++ test/DBTest.php | 149 ++-- test/MySQLCompatibilityTest.php | 43 + test/PgSQLCompatibilityTest.php | 43 + test/QueryAndTableCoverageTest.php | 94 ++ test/SQLBehaviorCoverageTest.php | 134 +++ test/Support/DbTestBootstrap.php | 101 +++ test/TableFromClassTest.php | 25 +- test/TableModelTest.php | 150 ++++ 28 files changed, 2287 insertions(+), 884 deletions(-) create mode 100644 docker-compose.yml create mode 100644 docker/php/Dockerfile create mode 100644 phpunit.xml create mode 100755 scripts/test-all-databases.sh create mode 100755 scripts/test-docker.sh delete mode 100644 src/DB/CrudTrait.php create mode 100644 src/DB/Exception/DBException.php create mode 100644 src/DB/Exception/InvalidQueryException.php create mode 100644 src/DB/Exception/ModelValidationException.php create mode 100644 src/DB/Exception/NotFoundException.php create mode 100644 src/DB/Exception/TransactionException.php create mode 100644 src/DB/Model.php create mode 100644 test/DBCoverageTest.php create mode 100644 test/MySQLCompatibilityTest.php create mode 100644 test/PgSQLCompatibilityTest.php create mode 100644 test/QueryAndTableCoverageTest.php create mode 100644 test/SQLBehaviorCoverageTest.php create mode 100644 test/Support/DbTestBootstrap.php create mode 100644 test/TableModelTest.php diff --git a/README.md b/README.md index 7294baa..ef6136b 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,202 @@ -DB ![Build Status](https://travis-ci.org/objectiveweb/db.svg?branch=master) -== +# objectiveweb/db -Getting Started ---------------- +Small database abstraction layer built on top of Doctrine DBAL. - use Objectiveweb\DB; +## Install - $db = new DB('pdo uri', 'username', 'password'); +```bash +composer require objectiveweb/db doctrine/dbal +``` - // general queries - $db->query('create table ...')->exec(); +## Getting started - // insert - $insert_id = $db->insert('table', array('field' => 'value', 'otherfield' => 'value')); +```php +use Objectiveweb\DB; - // update (table, values, conditions) - $affected_rows = $db->update('table', array('field' => 'newvalue', ...), array('field' => 'value')); +$db = DB::connect('mysql:dbname=app;host=127.0.0.1', 'user', 'secret'); - // select all rows - // returns array ( row1, row2, ...) - $rows = $db->select('table')->all(); +// Raw query +$db->query('CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255))')->exec(); - // map results by field - // returns associative array { 'row1_field_value' => row1, 'row2_field_value' => row2, ...) - $rows = $db->select('table')->map('field'); +// Insert +$insertId = $db->insert('users', ['name' => 'Alice']); - // select IN - $rows = $db->select('table', array('ID' => array( 1, 2, 3))->all(); +// Update (table, values, conditions) +$affectedRows = $db->update('users', ['name' => 'Alice Smith'], ['id' => 1]); - // select JOIN - $rows = $db->select('table', [], ['join' => [ - 'othertable o on o.some_id = table.id', // full join in single line - 'table1' => 'table1.some_id = table.id', // inner join table1 table1 on table1.some_id = table.id - '*table1' => 'table1.some_id = table.id' // left join table1 table1 on ... - ]); +// Select all rows +$rows = $db->select('users')->all(); - // select JOIN with raw query - $rows = $db->select('table', [], ['join' => 'othertable b on b.x = table.id']); +// Select with IN +$rows = $db->select('users', ['id' => [1, 2, 3]])->all(); - // fetch row by row - $query = $db->select('table'); +// Select with LIKE +$rows = $db->select('users', ['name' => 'Ali%'])->all(); - while($row = $query->fetch()) { - // process $row +// Map by field +$byId = $db->select('users')->map('id'); + +// Fetch row by row +$query = $db->select('users'); +while ($row = $query->fetch()) { + // process +} + +// Delete +$affectedRows = $db->delete('users', ['id' => 1]); + +// Transaction +$db->transaction(function (DB $db) { + $id = $db->insert('users', ['name' => 'Bob']); + $db->update('users', ['name' => 'Bobby'], ['id' => $id]); + + return $id; +}); +``` + +## CRUD operations + +```php +use Objectiveweb\DB; + +$db = DB::connect('mysql:dbname=app;host=127.0.0.1', 'user', 'secret'); + +$table = $db->table('users', [ + 'pk' => 'id', + 'join' => [], + 'model' => null, // optional: class-string to map rows into objects +]); + +// Insert +$id = $table->insert(['name' => 'Alice']); + +// Select all rows (returns Objectiveweb\DB\Collection) +$data = $table->select(); + +// Filter/sort/range +$data = $table->select([], [ + 'filter' => ['name' => 'Alice'], + 'sort' => [ + ['last_name', 'asc'], + ['id', 'desc'], + ], + 'range' => [0, 9], +]); + +$count = count($data); +$total = $data->total(); +$contentRange = $data->contentRange(); + +foreach ($data as $item) { + $item['name']; +} + +// Update by filter +$updated = $table->update(['name' => 'Alice'], ['name' => 'Alice Smith']); + +// Update by ID +$updated = $table->update(1, ['name' => 'Alice Smith']); +``` + +`select($filter, $params)` rule: when `$params['filter']` is provided, it is merged with `$filter` (`array_merge($filter, $params['filter'])`), so keys in `$params['filter']` win on conflicts. + +### Model mapping (optional) + +```php +use Objectiveweb\DB\Model; + +final class UserModel extends Model +{ + protected static array $validFields = ['name', 'age']; + + protected static array $creationRules = [ + 'name' => [ + 'required' => true, + 'filter' => FILTER_UNSAFE_RAW, + 'validate' => [self::class, 'validateName'], + ], + 'age' => [ + 'required' => true, + 'filter' => FILTER_VALIDATE_INT, + 'validate' => [self::class, 'validateAge'], + ], + ]; + + public static function validateName(mixed $value): bool|string + { + return is_string($value) && strlen($value) >= 2; } - // delete (table, conditions) - $db->delete('table', array('field' => 'value')); + public static function validateAge(mixed $value): bool|string + { + return is_int($value) && $value >= 0; + } +} - // transactions - $db->transaction(function() use ($somevar) { - $id = $db->insert(...); - $db->update(...); +$table = $db->table('users', ['model' => UserModel::class]); +$users = $table->select(); // Collection +$one = $table->get(1); // UserModel - if($condition) { - throw new \Exception('Error - transaction rolled back'); - } else { - return $id; - } - }); +// create/update payload is auto-filtered/validated against the model +$table->insert(['name' => 'Alice', 'age' => 31, 'ignored' => 'x']); // "ignored" is dropped +``` -CRUD Operations ---------------- +## Extending `DB\\Table` - use Objectiveweb\DB; +```php +use Objectiveweb\DB\Table; - $db = new DB(...); +class UserTable extends Table +{ + protected ?string $table = 'users'; - $table = $db->table('tablename', [ + protected ?array $params = [ 'pk' => 'id', - 'join' => [] - ]); + 'join' => [], + ]; +} - // Insert - $id = $table->post(array('field' => 'value', ...); +$table = $db->table(UserTable::class); +$table->insert(['name' => 'Alice']); +``` - // Select all rows (returns DB\Collection) - $table->index(); +## Filter grammar - // Get parameters - $data = $table->index([ - 'filter' => [ 'field' => 'value' ], - 'sort' => ['id', 'asc'], - 'range' => [ 0, 4 ] - ]); +`where` arrays support: - // Number of results - count($data); +- equality: `['id' => 10]` +- negation: `['!status' => 'archived']` +- `LIKE`: `['name' => 'Jo%']` +- `IN`: `['id' => [1, 2, 3]]` +- null checks: `['deleted_at' => null]`, `['!deleted_at' => null]` - // Total number of results (when using range) - $data->total(); +## Notes - foreach($data as $item) { - $item['field']; - } +- Table and field identifiers are validated before SQL generation. +- Raw string `where` clauses and raw join fragments are intentionally rejected for safety. +- `Collection::render()` does not emit HTTP headers. Use `Collection::contentRange()` if you need a `Content-Range` response header. - // Update (key, values) - $affected_rows = $table->put(array('name' = 'new name'), array('name' => 'old name')); +## Migration notes - // Update by ID - $affected_rows = $table->put(id, array('field' => 'new value')); +- Low-level internals moved from direct PDO usage to Doctrine DBAL. +- Pagination totals now use a dedicated `COUNT(*)` query instead of `SQL_CALC_FOUND_ROWS`. +- Transaction helpers throw typed exceptions (`TransactionException`) when begin/commit/rollback fails. +- Test suite defaults to SQLite in-memory, so local MySQL is no longer required. +## Stability policy -Extending DB\Table ------------------- +- Semantic Versioning is used for public APIs. +- Public stable APIs: `Objectiveweb\DB`, `Objectiveweb\DB\Table`, `Objectiveweb\DB\Collection`, `Objectiveweb\DB\Query`. +- Internal/private helpers in `DB` (identifier parsing/compilation methods) are not part of the public contract. - class MyTable extends Objectiveweb\DB\Table { - var $table = 'table_name'; - var $params = [ - 'pk => 'id', - 'join' => [] - ]; - } +## Test matrix (SQLite + MySQL + PostgreSQL) + +- Default local run (SQLite): `vendor/bin/phpunit --testsuite sqlite` +- MySQL run: `TEST_DB_DRIVER=mysql MYSQL_TEST_DSN=\"mysql:dbname=objectiveweb_test;host=127.0.0.1;port=3306;charset=utf8mb4\" MYSQL_TEST_USER=root MYSQL_TEST_PASSWORD=root vendor/bin/phpunit --testsuite mysql` +- PostgreSQL run: `TEST_DB_DRIVER=pgsql PGSQL_TEST_DSN=\"pgsql:dbname=objectiveweb_test;host=127.0.0.1;port=5432\" PGSQL_TEST_USER=postgres PGSQL_TEST_PASSWORD=postgres vendor/bin/phpunit --testsuite pgsql` - // then, instantiate it - $table = $db->table('MyTable'); +### Run all databases in Docker - $table->post(array('name' => 'new item')); +```bash +./scripts/test-docker.sh +``` diff --git a/composer.json b/composer.json index 334b6d8..867fea6 100644 --- a/composer.json +++ b/composer.json @@ -15,10 +15,11 @@ } ], "require": { - "php": ">=5.6.0" + "php": ">=8.0", + "doctrine/dbal": "^3.10 || ^4.0" }, "require-dev": { - "phpunit/phpunit": "4.*" + "phpunit/phpunit": "^9.6" }, "autoload": { "psr-4": {"Objectiveweb\\": "src/"} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3e0461e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + mysql: + image: mysql:8.4 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: objectiveweb_test + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] + interval: 5s + timeout: 5s + retries: 20 + + pgsql: + image: postgres:16 + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: objectiveweb_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d objectiveweb_test"] + interval: 5s + timeout: 5s + retries: 20 + + app: + build: + context: . + dockerfile: docker/php/Dockerfile + working_dir: /app + volumes: + - ./:/app + depends_on: + mysql: + condition: service_healthy + pgsql: + condition: service_healthy + environment: + MYSQL_TEST_DSN: mysql:dbname=objectiveweb_test;host=mysql;port=3306;charset=utf8mb4 + MYSQL_TEST_USER: root + MYSQL_TEST_PASSWORD: root + MYSQL_TEST_HOST: mysql + MYSQL_TEST_PORT: "3306" + MYSQL_TEST_DB: objectiveweb_test + PGSQL_TEST_DSN: pgsql:dbname=objectiveweb_test;host=pgsql;port=5432 + PGSQL_TEST_USER: postgres + PGSQL_TEST_PASSWORD: postgres + PGSQL_TEST_HOST: pgsql + PGSQL_TEST_PORT: "5432" + PGSQL_TEST_DB: objectiveweb_test + command: ["bash", "-lc", "sleep infinity"] diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..68dd1eb --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,17 @@ +FROM php:8.4-cli + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + unzip \ + libpq-dev \ + libsqlite3-dev \ + default-mysql-client \ + postgresql-client \ + && docker-php-ext-install pdo_mysql pdo_pgsql pdo_sqlite \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +RUN git config --global --add safe.directory /app + +WORKDIR /app diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..f9ca27f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + test + + + + test + test/MySQLCompatibilityTest.php + test/PgSQLCompatibilityTest.php + + + + test + test/PgSQLCompatibilityTest.php + + + + test + test/MySQLCompatibilityTest.php + + + diff --git a/scripts/test-all-databases.sh b/scripts/test-all-databases.sh new file mode 100755 index 0000000..38e97ec --- /dev/null +++ b/scripts/test-all-databases.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +git config --global --add safe.directory /app || true +export COMPOSER_ROOT_VERSION="${COMPOSER_ROOT_VERSION:-dev-main}" + +composer install --no-interaction --prefer-dist + +echo "==> Running SQLite suite" +TEST_DB_DRIVER=sqlite vendor/bin/phpunit --testsuite sqlite + +echo "==> Running MySQL suite" +TEST_DB_DRIVER=mysql vendor/bin/phpunit --testsuite mysql + +echo "==> Running PostgreSQL suite" +TEST_DB_DRIVER=pgsql vendor/bin/phpunit --testsuite pgsql diff --git a/scripts/test-docker.sh b/scripts/test-docker.sh new file mode 100755 index 0000000..fa30af3 --- /dev/null +++ b/scripts/test-docker.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +COMPOSE=${COMPOSE_CMD:-"docker compose"} + +# shellcheck disable=SC2086 +$COMPOSE up -d --build +# shellcheck disable=SC2086 +$COMPOSE exec app bash -lc ./scripts/test-all-databases.sh diff --git a/src/DB.php b/src/DB.php index 45f829d..65f7638 100644 --- a/src/DB.php +++ b/src/DB.php @@ -1,442 +1,688 @@ setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->pdo = $pdo; - $this->prefix = $prefix; + $this->connection = $connection; + $this->prefix = $prefix ?? ''; } /** - * Creates a new DB instance - * - * @param string $dsn driver:dbname=name;host=127.0.0.1;charset=utf8 - * - * The Data Source Name, or DSN, contains the information required to connect to the database. - * - * In general, a DSN consists of the PDO driver name, followed by a colon, followed by the PDO driver-specific connection syntax. Further information is available from the PDO driver-specific documentation. - * - * The dsn parameter supports three different methods of specifying the arguments required to create a database connection: - * - * Driver invocation - * dsn contains the full DSN. - * - * URI invocation - * dsn consists of uri: followed by a URI that defines the location of a file containing the DSN string. The URI can specify a local file or a remote URL. - * - * uri:file:///path/to/dsnfile - * - * Aliasing - * dsn consists of a name name that maps to pdo.dsn.name in php.ini defining the DSN string. - * - * @param string $username - * The user name for the DSN string. This parameter is optional for some PDO drivers. - * - * @param string $password - * The password for the DSN string. This parameter is optional for some PDO drivers. + * Creates a DB instance from DSN or parsed-url array. * - * @param array $options - * PDO key=>value array of driver-specific connection options. - * - * @return \Objectiveweb\DB + * @param string|array $dsn + * @param array $options */ - public static function connect($dsn, $username = null, $password = '', $options = array()) + public static function connect(string|array $dsn, ?string $username = null, string $password = '', array $options = []): self { + $prefix = isset($options['prefix']) ? (string) $options['prefix'] : ''; + unset($options['prefix']); - // parse dsn if necessary if (is_array($dsn)) { - $username = $dsn['user']; - $password = @$dsn['pass']; - $dsn = sprintf("%s:dbname=%s;host=%s;charset=utf8", - $dsn['scheme'], - isset($dsn['dbname']) ? $dsn['dbname'] : substr($dsn['path'], 1), - $dsn['host']); + $params = self::fromParsedUrl($dsn, $username, $password, $options); + } else { + $params = self::fromDsnString($dsn, $username, $password, $options); } - $prefix = @$options['prefix']; - unset($options['prefix']); - - return new DB(new PDO($dsn, $username, $password, $options), $prefix); - + return new self(DriverManager::getConnection($params), $prefix); } - function query($sql) + public function query(string $sql, mixed ...$args): Query { - if (func_num_args() > 1) { - $sql = call_user_func_array('sprintf', func_get_args()); + if ($args !== []) { + $sql = sprintf($sql, ...$args); } - $stmt = $this->pdo->prepare($sql); - - $query = new Query($stmt); + $query = new Query($this->connection, $sql); if ($this->debug) { - $query->sql = $sql; + $query->debugSql = $sql; error_log($sql); } return $query; } - /* Transactions ------------------------------------------------ */ - - function beginTransaction() + public function beginTransaction(): bool { - return $this->pdo->beginTransaction(); + try { + $this->connection->beginTransaction(); + return true; + } catch (\Throwable $e) { + throw new TransactionException('Cannot begin transaction', 500, $e); + } } - function rollBack() + public function rollBack(): bool { - return $this->pdo->rollBack(); + try { + $this->connection->rollBack(); + return true; + } catch (\Throwable $e) { + throw new TransactionException('Cannot rollback transaction', 500, $e); + } } - /** - * Returns TRUE on success or FALSE on failure. - */ - function commit() + public function commit(): bool { - return $this->pdo->commit(); + try { + $this->connection->commit(); + return true; + } catch (\Throwable $e) { + throw new TransactionException('Cannot commit transaction', 500, $e); + } } - function transaction($callable) + public function transaction(callable $callable): mixed { $this->beginTransaction(); try { - $ret = call_user_func($callable, $this); - - if (!$this->commit()) { - throw new \Exception('Cannot commit transaction', 500); - } + $ret = $callable($this); + $this->commit(); return $ret; - } catch (\Exception $ex) { - $this->rollBack(); + } catch (\Throwable $ex) { + try { + $this->rollBack(); + } catch (TransactionException $rollbackError) { + throw new TransactionException('Transaction rollback failed after error', 500, $rollbackError); + } throw $ex; } } - /* sql helpers ------------------------------------------------ */ - /** - * Performs a SELECT Query - * @param $table - * @param $where array [ field => value ] or string - * @param array $params [ key => value ] - * fields => comma-separated string or array. - * Non-numeric keys are used as field names, for example - * $fields = array( 'id', 'name', 'total' => 'COUNT(*)' ); - * group => null - * order => null - * limit => null - * offset => 0 - * join => array( - * 'table' => 'table.id = other.id', // table -> condition syntax - * 'othertable t on t.id = table.id' // raw string syntax - * ) - * - * @return \Objectiveweb\DB\Query - * @throws \Exception + * @param array|string|null $where + * @param array $params */ - function select($table, $where = null, $params = array()) + public function select(string $table, array|string|null $where = null, array $params = []): Query { - - $defaults = array( - 'fields' => '*', - 'group' => NULL, - 'order' => NULL, - 'limit' => NULL, + $defaults = [ + 'fields' => ['*'], + 'group' => null, + 'order' => null, + 'limit' => null, 'offset' => 0, - 'join' => '' - ); + 'join' => [], + ]; $params = array_merge($defaults, $params); - /** - * JOIN - */ - if (is_array($params['join'])) { - $join = ''; - foreach ($params['join'] as $k => $v) { - if (is_numeric($k)) { - $join .= " $v"; - } else { - if ($k[0] == '*') { - $join .= ' left'; - $k = ltrim($k, '*'); - } else { - $join .= ' inner'; - } + $tableAlias = $this->assertIdentifier($table); + $tableName = $this->prefix . $tableAlias; - $join_table = explode(" ", trim($k)); + $fields = $this->compileFields($params['fields']); + [$joinSql, $joinBindings] = $this->compileJoin((array) $params['join'], $tableAlias); + [$whereSql, $whereBindings] = $this->buildWhereClause($where); - if (count($join_table) > 1) { - $join_alias = $join_table[1]; - } else { - $join_alias = $join_table[0]; - } + $sql = sprintf( + 'SELECT %s FROM %s %s%s%s', + implode(', ', $fields), + $this->quoteIdentifier($tableName), + $this->quoteIdentifier($tableAlias), + $joinSql !== '' ? ' ' . $joinSql : '', + $whereSql !== '' ? ' WHERE ' . $whereSql : '' + ); - $join_table = $this->prefix . $join_table[0]; + if (!empty($params['group'])) { + $sql .= ' GROUP BY ' . $this->compileGroup($params['group']); + } - $join .= " join {$join_table} {$join_alias} on {$v}"; - } - } - } else { - $join = $params['join']; + if (!empty($params['order'])) { + $sql .= ' ORDER BY ' . $this->compileOrder($params['order']); } - /** - * FIELDS - */ - if (!is_array($params['fields'])) { - $params['fields'] = explode(",", $params['fields']); + if ($params['limit'] !== null) { + $sql .= sprintf(' LIMIT %d OFFSET %d', (int) $params['limit'], (int) $params['offset']); } - $fields = array(); + $query = $this->query($sql); + $query->exec(array_merge($joinBindings, $whereBindings)); - foreach ($params['fields'] as $k => $v) { + return $query; + } - // Allow * and functions - if (preg_match('/(\*|[A-Z]+\([^\)]+\)|[a-z]+\([^\)]+\)|SQL_CALC_FOUND_ROWS.*)/', $v)) { - $r = str_replace('`', '``', $v); - } else { - $r = "`" . implode('`.`', explode(".", str_replace('`', '``', $v))) . "`"; - } + /** + * @param array|string|null $where + * @param array $params + */ + public function count(string $table, array|string|null $where = null, array $params = []): int + { + $params = array_merge([ + 'join' => [], + 'group' => null, + ], $params); + + $tableAlias = $this->assertIdentifier($table); + $tableName = $this->prefix . $tableAlias; + + [$joinSql, $joinBindings] = $this->compileJoin((array) $params['join'], $tableAlias); + [$whereSql, $whereBindings] = $this->buildWhereClause($where); + + $base = sprintf( + ' FROM %s %s%s%s', + $this->quoteIdentifier($tableName), + $this->quoteIdentifier($tableAlias), + $joinSql !== '' ? ' ' . $joinSql : '', + $whereSql !== '' ? ' WHERE ' . $whereSql : '' + ); - if (!is_numeric($k)) { - $r .= sprintf(" as `%s`", str_replace('`', '``', $k)); - } + $bindings = array_merge($joinBindings, $whereBindings); - //$fields[] = $r; - $params['fields'][$k] = $r; + if (!empty($params['group'])) { + $groupSql = $this->compileGroup($params['group']); + $sql = 'SELECT COUNT(*) AS count FROM (SELECT 1' . $base . ' GROUP BY ' . $groupSql . ') ow_count'; + } else { + $sql = 'SELECT COUNT(*) AS count' . $base; } - list($where, $bindings) = $this->_where($where); + $result = $this->query($sql); + $result->exec($bindings); + $countRow = $result->fetch(); - $sql = sprintf(/** @lang text */ - "SELECT %s FROM `%s` %s %s %s", - implode(", ", $params['fields']), - $this->prefix . $table, - $table, - $join, - !empty($where) ? 'WHERE ' . $where : ''); + return isset($countRow['count']) ? (int) $countRow['count'] : 0; + } - if ($params['group']) { - if (is_array($params['group'])) { - throw new \Exception('not implemented, use a single field (string)'); - } else { - $sql .= sprintf(' GROUP BY %s', $params['group']); - } + /** @param array $data */ + public function insert(string $table, array $data): ?string + { + if ($data === []) { + throw new InvalidQueryException('Nothing to INSERT'); } - if (!empty($params['order'])) { - if (is_array($params['order'])) { - $params['order'] = implode(', ', $params['order']); - } - $sql .= sprintf(' ORDER BY %s', $params['order']); - } + $tableName = $this->prefix . $this->assertIdentifier($table); + $columns = []; + $placeholders = []; + $bindings = []; - if ($params['limit']) { - $sql .= sprintf(' LIMIT %d,%d', $params['offset'], $params['limit']); + foreach ($data as $field => $value) { + $column = $this->assertIdentifier((string) $field); + $columns[] = $this->quoteIdentifier($column); + $placeholders[] = ':' . $column; + $bindings[$column] = is_bool($value) ? (int) $value : $value; } - $query = $this->query($sql); + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $this->quoteIdentifier($tableName), + implode(', ', $columns), + implode(', ', $placeholders) + ); - $query->exec($bindings); + $affected = $this->query($sql)->exec($bindings); - return $query; + return $affected === 0 ? null : (string) $this->connection->lastInsertId(); } /** - * Inserts $data into $table - * - * @param $table - * @param $data array [ field => value, ... ] - * @return $id int Last Insert ID or NULL if no rows where changed + * @param array $data + * @param array|string|null $where */ - function insert($table, $data) + public function update(string $table, array $data, array|string|null $where = null, ?int $limit = null): int { + if ($data === []) { + throw new InvalidQueryException('Nothing to UPDATE'); + } - $fields = array_keys($data); + [$whereSql, $whereBindings] = $this->buildWhereClause($where); + if ($whereSql === '') { + throw new InvalidQueryException('Unsafe UPDATE without WHERE clause'); + } - $sql = "INSERT INTO " . $this->prefix . $table . " (" . implode(", ", $fields) . ") VALUES (:" . implode(", :", $fields) . ");"; + $tableName = $this->prefix . $this->assertIdentifier($table); + $changes = []; + $bindings = $whereBindings; - $query = $this->query($sql); - foreach ($fields as $field) { - $query->bind($field, is_bool($data[$field]) ? intval($data[$field]) : $data[$field]); + foreach ($data as $key => $value) { + $field = $this->assertIdentifier((string) $key); + $placeholder = 'update_' . $field; + $changes[] = sprintf('%s = :%s', $this->quoteIdentifier($field), $placeholder); + $bindings[$placeholder] = is_bool($value) ? (int) $value : $value; + } + + $sql = sprintf( + 'UPDATE %s SET %s WHERE %s%s', + $this->quoteIdentifier($tableName), + implode(', ', $changes), + $whereSql, + $limit !== null ? sprintf(' LIMIT %d', $limit) : '' + ); + + return $this->query($sql)->exec($bindings); + } + + /** @param array|string|null $where */ + public function delete(string $table, array|string|null $where): int + { + [$whereSql, $whereBindings] = $this->buildWhereClause($where); + if ($whereSql === '') { + throw new InvalidQueryException('Unsafe DELETE without WHERE clause'); } - $rows = $query->exec(); + $tableName = $this->prefix . $this->assertIdentifier($table); - return ($rows === 0) ? NULL : $this->pdo->lastInsertId(); + $sql = sprintf( + 'DELETE FROM %s WHERE %s', + $this->quoteIdentifier($tableName), + $whereSql + ); + + return $this->query($sql)->exec($whereBindings); } + public function debug(bool $status = true): void + { + $this->debug = $status; + } + + /** @param array $params */ + public function table(string $table, array $params = ['pk' => 'id']): Table + { + if (class_exists($table) && is_subclass_of($table, Table::class)) { + return new $table($this); + } + + return new Table($this, $table, $params); + } /** - * UPDATE - * - * @param String $table Table name - * @param array $data Data to update - * @param mixed $where conditions - * @return int number of updated rows - * @throws \Exception + * @param array $array + * @param list|string $validKeys + * @param array $defaults + * @return array */ - function update($table, $data, $where = null, $limit = null) + public static function array_cleanup(array $array, array|string $validKeys = [], array $defaults = []): array { + $keys = is_array($validKeys) ? $validKeys : [$validKeys]; + $cleanArray = array_intersect_key($array, array_flip($keys)); + return array_merge($defaults, $cleanArray); + } - $changes = array(); + public static function now(): string + { + return date('Y-m-d H:i:s'); + } + + /** + * @param array|string|null $args + * @return array{0:string,1:array} + */ + private function buildWhereClause(array|string|null $args = null, string $glue = 'AND'): array + { + if ($args === null || $args === '') { + return ['', []]; + } + + if (is_string($args)) { + throw new InvalidQueryException('Raw WHERE string is disabled. Use array conditions.'); + } - list($where, $bindings) = $this->_where($where); + $cond = []; + $bindings = []; - foreach ($data as $key => $value) { - $changes[] = "$key = :update_$key"; - $bindings[":update_$key"] = is_bool($value) ? intval($value) : $value; + foreach ($args as $key => $value) { + if (!is_string($key) || $key === '') { + throw new InvalidQueryException('Invalid WHERE key'); + } + + $not = false; + if ($key[0] === '!') { + $not = true; + $key = substr($key, 1); + } + + $field = $this->quoteIdentifierPath($this->assertIdentifier($key)); + $baseName = 'where_' . preg_replace('/[^A-Za-z0-9_]/', '_', $key) . '_' . count($bindings); + + if (is_array($value)) { + if ($value === []) { + $cond[] = $not ? '1=1' : '1=0'; + continue; + } + + $list = []; + foreach (array_values($value) as $i => $item) { + $name = $baseName . '_' . $i; + $list[] = ':' . $name; + $bindings[$name] = is_bool($item) ? (int) $item : $item; + } + + $cond[] = sprintf('%s %sIN (%s)', $field, $not ? 'NOT ' : '', implode(', ', $list)); + continue; + } + + if ($value === null) { + $cond[] = sprintf('%s IS %sNULL', $field, $not ? 'NOT ' : ''); + continue; + } + + $operator = '='; + if (is_string($value) && strpos($value, '%') !== false) { + $operator = $not ? 'NOT LIKE' : 'LIKE'; + } elseif ($not) { + $operator = '<>'; + } + + $cond[] = sprintf('%s %s :%s', $field, $operator, $baseName); + $bindings[$baseName] = is_bool($value) ? (int) $value : $value; + } + + return [implode(' ' . $glue . ' ', $cond), $bindings]; + } + + /** @param list|string $fields */ + private function compileFields(array|string $fields): array + { + $fields = is_array($fields) ? $fields : array_map('trim', explode(',', $fields)); + $compiled = []; + + foreach ($fields as $alias => $field) { + if (!is_string($field) || $field === '') { + throw new InvalidQueryException('Invalid SELECT field'); + } + + $rendered = $this->compileFieldToken($field); + if (!is_int($alias)) { + $rendered .= ' AS ' . $this->quoteIdentifier($this->assertIdentifier((string) $alias)); + } + $compiled[] = $rendered; } - if (empty($changes)) { - throw new \Exception("Nothing to UPDATE"); + if ($compiled === []) { + throw new InvalidQueryException('At least one field is required'); } - $sql = sprintf(/** @lang text */ - "UPDATE `%s` SET %s WHERE %s %s", - $this->prefix . $table, - implode(", ", $changes), - $where, - $limit ? "LIMIT $limit" : ""); + return $compiled; + } - $query = $this->query($sql); + private function compileFieldToken(string $field): string + { + $field = trim($field); + + if ($field === '*') { + return '*'; + } + + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*\.\*$/', $field) === 1) { + [$table] = explode('.', $field, 2); + return $this->quoteIdentifier($table) . '.*'; + } + + if (preg_match('/^(COUNT|SUM|AVG|MIN|MAX)\((\*|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\)$/i', $field, $matches) === 1) { + $function = strtoupper($matches[1]); + $target = $matches[2] === '*' ? '*' : $this->quoteIdentifierPath($this->assertIdentifier($matches[2])); + return sprintf('%s(%s)', $function, $target); + } - return $query->exec($bindings); + return $this->quoteIdentifierPath($this->assertIdentifier($field)); } /** - * Performs a DELETE query, returns number of affected rows - * - * @param string $table table name - * @param mixed $where condition - * @return int Number of affected rows - * @throws \Exception + * @param array $join + * @return array{0:string,1:array} */ - function delete($table, $where) + private function compileJoin(array $join, string $baseAlias): array { + if ($join === []) { + return ['', []]; + } + + $parts = []; - list($where, $bindings) = $this->_where($where); + foreach ($join as $key => $value) { + if (is_int($key)) { + throw new InvalidQueryException('Raw JOIN strings are disabled. Use table=>condition syntax.'); + } - $sql = sprintf(/** @lang text */ - "DELETE FROM `%s` WHERE %s", $this->prefix . $table, $where); + $tableDef = trim((string) $key); + $left = false; - $query = $this->query($sql); + if ($tableDef !== '' && $tableDef[0] === '*') { + $left = true; + $tableDef = ltrim($tableDef, '*'); + } - return $query->exec($bindings); + [$table, $alias] = $this->parseTableAlias($tableDef); + $condition = trim((string) $value); + if ($condition === '') { + throw new InvalidQueryException('Join condition cannot be empty'); + } + + $parts[] = sprintf( + '%s JOIN %s %s ON %s', + $left ? 'LEFT' : 'INNER', + $this->quoteIdentifier($this->prefix . $table), + $this->quoteIdentifier($alias), + $condition + ); + } + + unset($baseAlias); + return [implode(' ', $parts), []]; } - private function _where($args = null, $glue = "AND") + private function compileGroup(mixed $group): string { + if (is_array($group)) { + throw new InvalidQueryException('group expects a string field'); + } - $bindings = null; - - if ($args && is_array($args)) { - $cond = array(); - $bindings = array(); - $me = $this; + if (!is_string($group) || trim($group) === '') { + throw new InvalidQueryException('Invalid group value'); + } - // TODO suportar _and, _or - foreach ($args as $key => $value) { + return $this->quoteIdentifierPath($this->assertIdentifier(trim($group))); + } - if ($key[0] == '!') { - $key = substr($key, 1); - $not = true; - } else { - $not = false; + private function compileOrder(mixed $order): string + { + if (is_string($order)) { + $pieces = array_map('trim', explode(',', $order)); + $rendered = []; + foreach ($pieces as $piece) { + if ($piece === '') { + continue; } + $rendered[] = $this->compileOrderPiece($piece); + } + + if ($rendered === []) { + throw new InvalidQueryException('Invalid order clause'); + } - // TODO if is_numeric($key) - $table = str_replace('`', '``', $key); - $table = implode('`.`', explode(".", $table)); - $key = crc32($key); - - if (is_array($value)) { - // TODO quote array values - $cond[] = sprintf("`%s` IN (%s)", $table, implode(",", array_map(array($this, 'escape'), $value))); - } else { - $cond[] = sprintf("`%s` %s :where_%s", - $table, - is_null($value) ? - ($not ? 'IS NOT' : 'IS') : - (strpos($value, '%') !== FALSE ? // TODO allow escaping % - ($not ? 'NOT LIKE' : 'LIKE') : - ($not ? '<>' : '=')), - $key); - $bindings[":where_$key"] = $value; + return implode(', ', $rendered); + } + + if (is_array($order)) { + if ($order === []) { + throw new InvalidQueryException('Invalid order clause'); + } + + if ( + count($order) === 2 + && isset($order[0], $order[1]) + && is_string($order[0]) + && is_string($order[1]) + && in_array(strtoupper(trim($order[1])), ['ASC', 'DESC'], true) + ) { + return $this->compileOrderPiece($order[0] . ' ' . $order[1]); + } + + $rendered = []; + foreach ($order as $piece) { + if (is_array($piece)) { + if ( + !isset($piece[0]) + || !is_string($piece[0]) + || (isset($piece[1]) && !is_string($piece[1])) + ) { + throw new InvalidQueryException('Invalid order clause'); + } + + $rendered[] = $this->compileOrderPiece( + isset($piece[1]) ? $piece[0] . ' ' . $piece[1] : $piece[0] + ); + continue; } + + $rendered[] = $this->compileOrderPiece((string) $piece); } - $args = implode(" $glue ", $cond); + return implode(', ', $rendered); } - return array($args, $bindings); + throw new InvalidQueryException('Invalid order clause'); } - /** DB Functions */ - - /** - * Ativa debugging no db (grava queries, etc) - * @param bool|true $status - */ - function debug($status = array()) + private function compileOrderPiece(string $piece): string { - $this->debug = $status; + if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)(?:\s+(ASC|DESC))?$/i', trim($piece), $matches) !== 1) { + throw new InvalidQueryException('Invalid order expression'); + } + + $field = $this->quoteIdentifierPath($this->assertIdentifier($matches[1])); + $dir = isset($matches[2]) ? ' ' . strtoupper($matches[2]) : ''; + + return $field . $dir; } - /** - * Returns a DB\Table helper for this table - * @param $table String the table name - * @param array $params Optional Primary Key, defaults to 'id' - * @return DB\Table - */ - function table($table, array $params = ['pk' => 'id']) + /** @return array{0:string,1:string} */ + private function parseTableAlias(string $tableDef): array { - if (class_exists($table) && is_subclass_of($table, 'Objectiveweb\DB\Table')) { + $parts = preg_split('/\s+/', trim($tableDef)); + if (!is_array($parts) || $parts === []) { + throw new InvalidQueryException('Invalid table definition'); + } - return new $table($this); - } else { - return new DB\Table($this, $table, $params); + $table = $this->assertIdentifier($parts[0]); + $alias = isset($parts[1]) ? $this->assertIdentifier($parts[1]) : $table; + + return [$table, $alias]; + } + + private function assertIdentifier(string $identifier): string + { + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/', $identifier) !== 1) { + throw new InvalidQueryException("Invalid identifier: {$identifier}"); } + + return $identifier; } - function escape($string) + private function quoteIdentifierPath(string $identifier): string { - return $this->pdo->quote($string); + $segments = explode('.', $identifier); + $segments = array_map(fn (string $segment): string => $this->quoteIdentifier($segment), $segments); + return implode('.', $segments); } - public static function array_cleanup(array $array, array $valid_keys = [], $defaults = []) + private function quoteIdentifier(string $identifier): string { - if (!is_array($valid_keys)) { - $valid_keys = array($valid_keys); + return $this->connection->quoteIdentifier($identifier); + } + + /** + * @param array $dsn + * @param array $options + * @return array + */ + private static function fromParsedUrl(array $dsn, ?string $username, string $password, array $options): array + { + $scheme = (string) ($dsn['scheme'] ?? 'mysql'); + $dbName = isset($dsn['dbname']) ? (string) $dsn['dbname'] : ltrim((string) ($dsn['path'] ?? ''), '/'); + + $params = [ + 'driver' => self::mapDriver($scheme), + 'host' => $dsn['host'] ?? '127.0.0.1', + 'dbname' => $dbName, + 'user' => $dsn['user'] ?? $username, + 'password' => $dsn['pass'] ?? $password, + ]; + + if (isset($dsn['port'])) { + $params['port'] = (int) $dsn['port']; } - $clean_array = array_intersect_key($array, array_flip($valid_keys)); - return array_merge($defaults, $clean_array); + + return array_merge($params, $options); } - public static function now() + /** + * @param array $options + * @return array + */ + private static function fromDsnString(string $dsn, ?string $username, string $password, array $options): array { - return date('Y-m-d H:i:s'); + if (strpos($dsn, ':') === false) { + throw new InvalidQueryException('Invalid DSN format'); + } + + [$scheme, $rest] = explode(':', $dsn, 2); + + if ($scheme === 'sqlite') { + $path = trim($rest); + if (str_starts_with($path, 'dbname=')) { + $path = substr($path, strlen('dbname=')); + } + + $params = [ + 'driver' => 'pdo_sqlite', + 'path' => $path, + 'user' => $username, + 'password' => $password, + ]; + + return array_merge($params, $options); + } + + $pairs = []; + foreach (explode(';', $rest) as $chunk) { + if ($chunk === '' || strpos($chunk, '=') === false) { + continue; + } + [$key, $value] = explode('=', $chunk, 2); + $pairs[trim($key)] = trim($value); + } + + $params = [ + 'driver' => self::mapDriver($scheme), + 'host' => $pairs['host'] ?? '127.0.0.1', + 'dbname' => $pairs['dbname'] ?? '', + 'charset' => $pairs['charset'] ?? 'utf8', + 'user' => $username, + 'password' => $password, + ]; + + return array_merge($params, $options); + } + + private static function mapDriver(string $scheme): string + { + return match ($scheme) { + 'mysql' => 'pdo_mysql', + 'pgsql' => 'pdo_pgsql', + 'sqlite' => 'pdo_sqlite', + 'sqlsrv' => 'pdo_sqlsrv', + default => throw new InvalidQueryException("Unsupported driver scheme: {$scheme}"), + }; } } diff --git a/src/DB/Collection.php b/src/DB/Collection.php index c18adac..4aab0a6 100644 --- a/src/DB/Collection.php +++ b/src/DB/Collection.php @@ -1,80 +1,91 @@ */ + private array $data; + private int $startIndex; + private int $endIndex; + private int $total; - function __construct($data, $startIndex = 0, $endIndex = null, $total = null) + /** @param list $data */ + public function __construct(array $data, int $startIndex = 0, ?int $endIndex = null, ?int $total = null) { $this->data = $data; - $this->startIndex = $startIndex; - $this->endIndex = $endIndex === null ? count($data) - 1 : $endIndex; - $this->total = $total === null ? count($data) : $total; - + $this->endIndex = $endIndex ?? (count($data) - 1); + $this->total = $total ?? count($data); } - function data($key = null) + /** @return list|mixed */ + public function data(?int $key = null): mixed { if ($key !== null) { return $this->data[$key]; - } else { - return $this->data; } + + return $this->data; } - public function total() + public function total(): int { return $this->total; } - function render($content_type = "") + public function contentRange(): string { - switch ($content_type) { - default: - header(sprintf("Content-Range: items %d-%d/%d", $this->startIndex, $this->endIndex, $this->total)); - return json_encode($this->data); - } + return sprintf('items %d-%d/%d', $this->startIndex, $this->endIndex, $this->total); } - public function jsonSerialize() + public function render(string $contentType = 'application/json'): string + { + unset($contentType); + return json_encode($this->data, JSON_THROW_ON_ERROR); + } + + /** @return list */ + public function jsonSerialize(): array { return $this->data; } - public function offsetExists($offset) + public function offsetExists(mixed $offset): bool { - return array_key_exists($offset, $this->data); + return array_key_exists((int) $offset, $this->data); } - public function &offsetGet($offset) + public function &offsetGet(mixed $offset): mixed { - return $this->data[$offset]; + return $this->data[(int) $offset]; } - public function offsetSet($offset, $value) + public function offsetSet(mixed $offset, mixed $value): void { - $this->data[$offset] = $value; + if ($offset === null) { + $this->data[] = $value; + return; + } + + $this->data[(int) $offset] = $value; } - public function offsetUnset($offset) + public function offsetUnset(mixed $offset): void { - unset($this->data[$offset]); + unset($this->data[(int) $offset]); } - public function count() + public function count(): int { return count($this->data); } - public function &getIterator() + public function getIterator(): \Traversable { - foreach($this->data as $key => &$val) { + foreach ($this->data as $key => $val) { yield $key => $val; } } diff --git a/src/DB/CrudTrait.php b/src/DB/CrudTrait.php deleted file mode 100644 index 88f414d..0000000 --- a/src/DB/CrudTrait.php +++ /dev/null @@ -1,204 +0,0 @@ -crudSetup($db, $table, $pk) on its constructor - */ -trait CrudTrait -{ - /** @var \Objectiveweb\DB */ - protected $db; - - /** @var String */ - protected $table = null; - - protected $params = null; - - protected function crudSetup(DB $db, $table, $params = []) - { - $this->db = $db; - - if (!$this->table) { - $this->table = $table; - } - - if (!$this->params) { - $this->params = array_merge([ - 'pk' => 'id', - 'join' => [], - 'fields' => ["$this->table.*"] - ], $params); - } - - - $this->params['pk'] = implode('`.`', explode('.', $this->params['pk'])); - - } - - public function index($params = [], $filter = []) - { - return $this->select($filter, $params); - } - - private function parse_params($params) - { - // Other query parameters - $queryparams = []; - if (empty($params['fields'])) { - $params['fields'] = $this->params['fields']; - } - - if (!is_array($params['fields'])) { - $params['fields'] = explode(',', $params['fields']); - } - - $first_field = array_key_first($params['fields']); - $params['fields'][$first_field] = 'SQL_CALC_FOUND_ROWS ' . $params['fields'][$first_field]; - - $queryparams['fields'] = $params['fields']; - - if (!empty($params['sort'])) { - $queryparams['order'] = $params['sort']; - } - - if (!empty($params['range'])) { - if (!is_array($params['range'])) { - $params['range'] = json_decode($params['range']); - } - $queryparams['offset'] = $params['range'][0]; - $queryparams['limit'] = $params['range'][1] - $params['range'][0] + 1; - } else { - $queryparams['offset'] = 0; - } - - $queryparams['join'] = isset($params['join']) ? $params['join'] : $this->params['join']; - $queryparams['group'] = isset($params['group']) ? $params['group'] : $this->params['group']; - - return $queryparams; - } - /** - * select(array $filter, array $params) - * Returns a list of rows matching $query. - * - * @param array $filter Default filter that is merged with params, overrides any user-provided values - * @param array $params - * You can define - * $params[range] range of records to return - * $params[sort] the results sort order - * @return Collection - * @throws \Exception - */ - - public function select(array $filter = [], array $params = []): Collection - { - // Where clause - if (!empty($params['filter'])) { - if (!is_array($params['filter'])) { - $where = array_merge(json_decode($params['filter'], true), $filter); - } else { - $where = array_merge($params['filter'], $filter); - } - } else { - $where = $filter; - } - - $queryparams = $this->parse_params($params); - - $query = $this->db->select($this->table, $where, $queryparams); - - $rows_query = $this->db->query('SELECT FOUND_ROWS() as count'); - $rows_query->exec(); - - $rows = $rows_query->fetch(); - - if (!$rows) { - throw new \Exception('Error while FOUND_ROWS()', 500); - } - - $data = $query->all(); - $rowscount = intval($rows['count']); - - return new Collection($data, $queryparams['offset'], $queryparams['offset'] + count($data) - 1, $rowscount); - } - - /** - * Retrieves records from table - * - * get() - * get($query = array()) - * @param mixed $key - * @param array $params - * @return mixed - * @see index($query = array()) - * get($key, $params = array()) - * Returns row with key $key, with optional select $params - * - */ - public function get($key = null, $params = []) - { - if (empty($key) || is_array($key)) { - return $this->select($key, $params); - } - - $params = $this->parse_params($params); - - // get single - $key = sprintf('`%s`.`%s` = %s', $this->table, $this->params['pk'], $this->db->escape($key)); - $query = $this->db->select($this->table, $key, $params); - - if (!$rsrc = $query->fetch()) { - throw new \Exception('Record not found', 404); - } - - return $rsrc; - } - - public function insert(array $data): ?array - { - return $this->post($data); - } - - public function post($data) - { - $id = $this->db->insert($this->table, $data); - - return $id ? [$this->params['pk'] => $id] : null; - } - - public function put($key, $data) - { - if (!is_array($key)) { - $key = array($this->params['pk'] => $key); - } - - return array('updated' => $this->db->update($this->table, $data, $key)); - } - - public function delete($key) - { - if (!is_array($key)) { - $key = array($this->params['pk'] => $key); - } - - // TODO support cascade delete com joins? - - return $this->db->delete($this->table, $key); - } - - public function findBy($key, $value) - { - return $this->select([ - 'filter' => [ - $key => $value - ] - ]); - } -} diff --git a/src/DB/Exception/DBException.php b/src/DB/Exception/DBException.php new file mode 100644 index 0000000..c724174 --- /dev/null +++ b/src/DB/Exception/DBException.php @@ -0,0 +1,9 @@ + */ + protected static array $validFields = []; + + /** @var array> */ + protected static array $creationRules = []; + + /** @var array> */ + protected static array $updateRules = []; + + /** @var array */ + protected array $attributes = []; + + /** @param array $data */ + public function __construct(array $data = []) + { + $this->fill($data); + } + + /** @param array $data @return array */ + public static function normalizeForCreate(array $data): array + { + $filtered = static::filterValidFields($data); + $filtered = static::applyRules($filtered, static::$creationRules, true); + + if ($filtered === []) { + throw new ModelValidationException(static::class . ' has no valid fields for creation'); + } + + return $filtered; + } + + /** @param array $data @return array */ + public static function normalizeForUpdate(array $data): array + { + $filtered = static::filterValidFields($data); + $rules = static::$updateRules !== [] ? static::$updateRules : static::$creationRules; + $filtered = static::applyRules($filtered, $rules, false); + + if ($filtered === []) { + throw new ModelValidationException(static::class . ' has no valid fields for update'); + } + + return $filtered; + } + + /** @param array $data */ + public function fill(array $data): self + { + foreach (static::filterValidFields($data) as $key => $value) { + $this->attributes[$key] = $value; + + if (!property_exists($this, $key)) { + continue; + } + + $property = new \ReflectionProperty($this, $key); + if (!$property->isPublic()) { + continue; + } + + try { + $this->{$key} = $value; + } catch (\TypeError) { + // Keep raw value in attributes even when public typed property rejects it. + } + } + + return $this; + } + + /** @return array */ + public function toArray(): array + { + return $this->attributes; + } + + public function jsonSerialize(): array + { + return $this->attributes; + } + + public function __get(string $name): mixed + { + return $this->attributes[$name] ?? null; + } + + public function __set(string $name, mixed $value): void + { + $this->attributes[$name] = $value; + } + + /** @param array $data @return array */ + protected static function filterValidFields(array $data): array + { + $validFields = static::$validFields; + if ($validFields === []) { + return $data; + } + + return array_intersect_key($data, array_flip($validFields)); + } + + /** + * @param array $data + * @param array> $rules + * @return array + */ + protected static function applyRules(array $data, array $rules, bool $isCreate): array + { + $normalized = $data; + + foreach ($rules as $field => $fieldRules) { + $exists = array_key_exists($field, $normalized); + + if (!$exists) { + if ($isCreate && (($fieldRules['required'] ?? false) === true)) { + throw new ModelValidationException("{$field} is required"); + } + + continue; + } + + $value = $normalized[$field]; + + if ($value === null) { + $nullable = ($fieldRules['nullable'] ?? false) === true; + if (!$nullable && (($fieldRules['required'] ?? false) === true)) { + throw new ModelValidationException("{$field} cannot be null"); + } + + continue; + } + + if (isset($fieldRules['filter'])) { + $value = static::applyPhpFilter($field, $value, $fieldRules); + } + + if (isset($fieldRules['validate'])) { + static::runCustomValidator($field, $value, $normalized, $fieldRules['validate'], $isCreate); + } + + if (isset($fieldRules['type']) && !static::isOfType($value, (string) $fieldRules['type'])) { + throw new ModelValidationException("{$field} must be of type {$fieldRules['type']}"); + } + + if (isset($fieldRules['enum']) && is_array($fieldRules['enum']) && !in_array($value, $fieldRules['enum'], true)) { + throw new ModelValidationException("{$field} has an invalid value"); + } + + if (isset($fieldRules['pattern']) && is_string($fieldRules['pattern']) && is_string($value)) { + if (preg_match($fieldRules['pattern'], $value) !== 1) { + throw new ModelValidationException("{$field} format is invalid"); + } + } + + if (isset($fieldRules['min'])) { + static::assertMin($field, $value, $fieldRules['min']); + } + + if (isset($fieldRules['max'])) { + static::assertMax($field, $value, $fieldRules['max']); + } + + $normalized[$field] = $value; + } + + return $normalized; + } + + private static function isOfType(mixed $value, string $type): bool + { + return match ($type) { + 'int', 'integer' => is_int($value), + 'float', 'double' => is_float($value), + 'numeric' => is_numeric($value), + 'string' => is_string($value), + 'bool', 'boolean' => is_bool($value), + 'array' => is_array($value), + default => true, + }; + } + + private static function assertMin(string $field, mixed $value, mixed $min): void + { + if (is_numeric($value) && $value < $min) { + throw new ModelValidationException("{$field} must be >= {$min}"); + } + + if (is_string($value) && strlen($value) < (int) $min) { + throw new ModelValidationException("{$field} length must be >= {$min}"); + } + } + + private static function assertMax(string $field, mixed $value, mixed $max): void + { + if (is_numeric($value) && $value > $max) { + throw new ModelValidationException("{$field} must be <= {$max}"); + } + + if (is_string($value) && strlen($value) > (int) $max) { + throw new ModelValidationException("{$field} length must be <= {$max}"); + } + } + + /** @param array $fieldRules */ + private static function applyPhpFilter(string $field, mixed $value, array $fieldRules): mixed + { + $filter = $fieldRules['filter']; + if (!is_int($filter)) { + throw new ModelValidationException("{$field} has an invalid filter definition"); + } + + $options = []; + + if (array_key_exists('filter_options', $fieldRules)) { + $options['options'] = $fieldRules['filter_options']; + } + + if (array_key_exists('filter_flags', $fieldRules)) { + $options['flags'] = $fieldRules['filter_flags']; + } + + $flags = (int) ($options['flags'] ?? 0); + $options['flags'] = $flags | FILTER_NULL_ON_FAILURE; + + $filtered = filter_var($value, $filter, $options); + if ($filtered === null || $filtered === false) { + throw new ModelValidationException("{$field} failed filter validation"); + } + + return $filtered; + } + + /** @param array $data */ + private static function runCustomValidator( + string $field, + mixed $value, + array $data, + mixed $validator, + bool $isCreate + ): void { + if (!is_callable($validator)) { + throw new ModelValidationException("{$field} has a non-callable validator"); + } + + $result = $validator($value, $field, $data, $isCreate); + if ($result === false) { + throw new ModelValidationException("{$field} failed custom validation"); + } + + if (is_string($result) && $result !== '') { + throw new ModelValidationException($result); + } + } +} diff --git a/src/DB/Query.php b/src/DB/Query.php index 0a34bfb..3158fe7 100644 --- a/src/DB/Query.php +++ b/src/DB/Query.php @@ -1,110 +1,121 @@ */ + private array $bindings = []; - function __construct($stmt) { - $this->error = null; - $this->stmt = $stmt; - } + private ?Result $result = null; - /** - * - * Binds $value to $pos - * - * from http://stackoverflow.com/a/6743773/164469 - * - * @param $pos string "field" - * @param $value mixed [value] - * @param null $type PDO::PARAM_* code - * @return $this - */ - public function bind($pos, $value, $type = null) - { - if (is_null($type)) { - switch (true) { - case is_int($value): - $type = PDO::PARAM_INT; - break; - case is_bool($value): - $type = PDO::PARAM_BOOL; - break; - case is_null($value): - $type = PDO::PARAM_NULL; - break; - default: - $type = PDO::PARAM_STR; - } - } + public ?string $debugSql = null; - $this->stmt->bindValue(":$pos", $value, $type); + public function __construct(Connection $connection, string $sql) + { + $this->connection = $connection; + $this->sql = $sql; + } + public function bind(string $pos, mixed $value): self + { + $this->bindings[ltrim($pos, ':')] = $value; return $this; } /** - * Executes the current statement, returns the number of modified rows - * - * @param array $bindings [ ":field" => "value", ... ] - * @throws \PDOException - * @throws \Exception when an error occurs + * @param array|null $bindings */ - function exec($bindings = null) + public function exec(?array $bindings = null): int { - $res = $this->stmt->execute($bindings); + $params = $this->normalizeBindings($bindings); + $this->freeResult(); - if ($res !== false) { - return $this->stmt->rowCount(); - } else { - throw new \Exception(json_encode($this->stmt->errorInfo()), 500); + if ($this->isResultSetQuery($this->sql)) { + $this->result = $this->connection->executeQuery($this->sql, $params); + return $this->result->rowCount(); } + + return $this->connection->executeStatement($this->sql, $params); } - /** - * Fetches a row from a result set associated with the current Statement. - * - * @return array - */ - function fetch() + /** @return array|false */ + public function fetch(): array|false { - return $this->stmt->fetch(PDO::FETCH_ASSOC); + if ($this->result === null) { + return false; + } + + $row = $this->result->fetchAssociative(); + return $row === false ? false : $row; } - /** - * Returns an array containing all of the result set rows - * - * @return array - */ - function all() + /** @return list> */ + public function all(): array { - return $this->stmt->fetchAll(PDO::FETCH_ASSOC); + if ($this->result === null) { + return []; + } + + return $this->result->fetchAllAssociative(); } + /** @return array> */ + public function map(string $field): array + { + if ($this->result === null) { + return []; + } + + $mapped = []; + + while (($row = $this->result->fetchAssociative()) !== false) { + if (!array_key_exists($field, $row)) { + throw new InvalidQueryException("Invalid field {$field}"); + } + + $mapped[$row[$field]] = $row; + } + + return $mapped; + } /** - * Returns an associative array with all the result set rows mapped by $field - * @param string $field the field to index + * @param array|null $bindings + * @return array */ - function map($field) { - $map = array(); + private function normalizeBindings(?array $bindings): array + { + $params = $this->bindings; - while($row = $this->stmt->fetch(PDO::FETCH_ASSOC)) { - if(!isset($row[$field])) { - throw new \Exception("Invalid field $field", 500); + if ($bindings !== null) { + foreach ($bindings as $key => $value) { + $params[ltrim((string) $key, ':')] = $value; } - - $map[$row[$field]] = $row; } - return $map; + return $params; } + private function isResultSetQuery(string $sql): bool + { + return (bool) preg_match('/^\s*(SELECT|SHOW|DESCRIBE|PRAGMA|WITH)\b/i', $sql); + } -} \ No newline at end of file + private function freeResult(): void + { + if ($this->result !== null) { + $this->result->free(); + $this->result = null; + } + } +} diff --git a/src/DB/Table.php b/src/DB/Table.php index 1ac635c..6b74492 100644 --- a/src/DB/Table.php +++ b/src/DB/Table.php @@ -1,23 +1,219 @@ |null */ + protected ?array $params = null; + + /** @param array $params */ + public function __construct(DB $db, ?string $table = null, array $params = []) + { + $this->db = $db; + + if ($this->table === null) { + $this->table = $table; + } + + if ($this->params === null) { + $this->params = array_merge([ + 'pk' => 'id', + 'join' => [], + 'group' => null, + 'fields' => ['*'], + 'model' => null, + ], $params); + } + + if($this->params['model']) { + if (!is_subclass_of($this->params['model'], Model::class)) { + throw new InvalidQueryException("Invalid model class: {$this->params['model']}"); + } + + $this->modelClass = new \ReflectionClass($this->params['model']); + } + } + + /** @param array $params @return array */ + private function parseParams(array $params): array + { + $queryParams = []; + + if (empty($params['fields'])) { + $params['fields'] = $this->params['fields']; + } + + if (!is_array($params['fields'])) { + $params['fields'] = array_map('trim', explode(',', (string) $params['fields'])); + } + + $queryParams['fields'] = $params['fields']; + + if (!empty($params['sort'])) { + $queryParams['order'] = $params['sort']; + } + + if (!empty($params['range'])) { + if (!is_array($params['range'])) { + $params['range'] = (array) json_decode((string) $params['range'], true); + } + $queryParams['offset'] = (int) $params['range'][0]; + $queryParams['limit'] = (int) $params['range'][1] - (int) $params['range'][0] + 1; + } else { + $queryParams['offset'] = 0; + } - /** - * Table is a controller for a DB table - * Usually instantiated via $db->table('tablename' [, [ 'pk' => 'id'] ]); - * - * @param \Objectiveweb\DB $db DB instance - * @param string $table table name - * @param string $params Optional defaults to [ 'pk' => 'id', 'join' => [] ] - */ - public function __construct(DB $db, $table = null, $params = []) + $queryParams['join'] = $params['join'] ?? $this->params['join']; + $queryParams['group'] = $params['group'] ?? $this->params['group']; + + return $queryParams; + } + + /** @param array $filter @param array $params */ + public function select(array $filter = [], array $params = []): Collection + { + if (!empty($params['filter'])) { + if (!is_array($params['filter'])) { + $decoded = json_decode((string) $params['filter'], true); + $where = array_merge($filter, is_array($decoded) ? $decoded : []); + } else { + $where = array_merge($filter, $params['filter']); + } + } else { + $where = $filter; + } + + $queryParams = $this->parseParams($params); + $query = $this->db->select((string) $this->table, $where, $queryParams); + + $countParams = [ + 'join' => $queryParams['join'] ?? [], + 'group' => $queryParams['group'] ?? null, + ]; + + $rowsCount = $this->db->count((string) $this->table, $where, $countParams); + $data = $this->hydrateRows($query->all()); + + return new Collection( + $data, + (int) ($queryParams['offset'] ?? 0), + (int) ($queryParams['offset'] ?? 0) + count($data) - 1, + $rowsCount + ); + } + + /** @param mixed $key @param array $params @return Collection|array|object */ + public function get(mixed $key = null, array $params = []): mixed { - $this->crudSetup($db, $table, $params); + if ($key === null || is_array($key)) { + return $this->select(is_array($key) ? $key : [], $params); + } + + $params = $this->parseParams($params); + $where = [(string) $this->params['pk'] => $key]; + $query = $this->db->select((string) $this->table, $where, array_merge($params, ['limit' => 1])); + + $record = $query->fetch(); + if ($record === false) { + throw new NotFoundException('Record not found', 404); + } + + return $this->hydrateRow($record); } + + /** @param array|Model $data */ + public function insert(array|Model $data): ?array + { + $id = $this->db->insert((string) $this->table, $this->normalizeCreateData($data)); + + return $id ? [(string) $this->params['pk'] => $id] : null; + } + + /** @param int|string|array $key @param array|Model $data @return array */ + public function update(int|string|array $key, array|Model $data): array + { + if (!is_array($key)) { + $key = [(string) $this->params['pk'] => $key]; + } + + return ['updated' => $this->db->update((string) $this->table, $this->normalizeUpdateData($data), $key)]; + } + + /** @param int|string|array $key */ + public function delete(int|string|array $key): int + { + if (!is_array($key)) { + $key = [(string) $this->params['pk'] => $key]; + } + + return $this->db->delete((string) $this->table, $key); + } + + public function findBy(string $key, mixed $value): Collection + { + return $this->select([], [ + 'filter' => [ + $key => $value, + ], + ]); + } + + /** @param list> $rows @return list|object> */ + private function hydrateRows(array $rows): array + { + if (!$this->modelClass) { + return $rows; + } + + return array_map(fn (array $row): object => $this->modelClass->newInstance($row), $rows); + } + + /** @param array $row */ + private function hydrateRow(array $row): array|object + { + if (!$this->modelClass) { + return $row; + } + + return $this->modelClass->newInstance($row); + } + + /** @param array|Model $data @return array */ + private function normalizeCreateData(array|Model $data): array + { + $payload = $data instanceof Model ? $data->toArray() : $data; + + if (!$this->modelClass) { + return $payload; + } + + $modelClass = (string) $this->params['model']; + return $modelClass::normalizeForCreate($payload); + } + + /** @param array|Model $data @return array */ + private function normalizeUpdateData(array|Model $data): array + { + $payload = $data instanceof Model ? $data->toArray() : $data; + + if (!$this->modelClass) { + return $payload; + } + + $modelClass = (string) $this->params['model']; + return $modelClass::normalizeForUpdate($payload); + } + } diff --git a/test/CrudTest.php b/test/CrudTest.php index 87227d7..22d45b3 100644 --- a/test/CrudTest.php +++ b/test/CrudTest.php @@ -1,62 +1,55 @@ > */ + protected static array $testData = [ 1 => ['name' => 'test', 'f1' => 'test', 'f2' => 'test', 'f3' => 'test'], 2 => ['name' => 'test1', 'f1' => 'test1', 'f2' => 'test1', 'f3' => 'test1'], 3 => ['name' => 'test2', 'f1' => 'test2', 'f2' => 'test2', 'f3' => 'test2'], 4 => ['name' => 'test3', 'f1' => 'test3', 'f2' => 'test3', 'f3' => 'test3'], - 5 => ['name' => null, 'f1' => 'test3', 'f2' => 'test3', 'f3' => 'test3'] + 5 => ['name' => null, 'f1' => 'test3', 'f2' => 'test3', 'f3' => 'test3'], ]; - public static function setUpBeforeClass() + public static function setUpBeforeClass(): void { - $db = DB::connect('mysql:dbname=objectiveweb;host=127.0.0.1', 'root', getenv('MYSQL_PASSWORD')); - $db->query('drop table if exists db_test')->exec(); - - $db->query('create table db_test - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255), `f1` VARCHAR(255), `f2` VARCHAR(255), `f3` VARCHAR(255));')->exec(); - - self::$table = $db->table('db_test'); - + $db = DbTestBootstrap::connect(); + DbTestBootstrap::createDbTestTable($db, true); + static::$table = $db->table('db_test'); } - public function testInsert() + public function testInsert(): void { foreach (self::$testData as $k => $v) { - $id = self::$table->post($v); - $this->assertEquals($k, $id['id']); + $id = static::$table->insert($v); + $this->assertEquals($k, (int) $id['id']); } } - /** - * @depends testInsert - */ - public function testIndex() + /** @depends testInsert */ + public function testIndex(): void { - $rows = self::$table->index(); + $rows = static::$table->select(); $this->assertEquals(5, count($rows)); $this->assertEquals('test', $rows[0]['name']); - $rows = self::$table->get(); + $rows = static::$table->get(); $this->assertEquals(5, count($rows)); $this->assertEquals('test', $rows[0]['name']); $count = 0; - foreach ($rows as $key => $value) { + foreach ($rows as $value) { $this->assertEquals(self::$testData[$value['id']]['name'], $value['name']); $count++; } @@ -64,73 +57,79 @@ public function testIndex() $this->assertEquals(5, $count); } - /** - * @depends testInsert - */ - public function testFields() { - $data = self:: $table->index(['fields' => ['id', 'f1', 'f2']]); + /** @depends testInsert */ + public function testFields(): void + { + $data = static::$table->select([], ['fields' => ['id', 'f1', 'f2']]); - foreach($data as $result) { + foreach ($data as $result) { $this->assertCount(3, $result); $this->assertEquals($result['f1'], self::$testData[$result['id']]['f1']); $this->assertEquals($result['f2'], self::$testData[$result['id']]['f2']); } } - /** - * @depends testInsert - */ - public function testPagination() + /** @depends testInsert */ + public function testPagination(): void { - $data = self::$table->index(array('range' => [0, 1], 'sort' => ['id', 'asc'])); + $data = static::$table->select([], ['range' => [0, 1], 'sort' => ['id', 'asc']]); $this->assertEquals(2, count($data)); $this->assertEquals('test', $data[0]['name']); $this->assertEquals('test1', $data[1]['name']); $this->assertEquals(5, $data->total()); - $data = self::$table->index(array('range' => [2, 3], 'sort' => ['id', 'asc'])); + $data = static::$table->select([], ['range' => [2, 3], 'sort' => ['id', 'asc']]); $this->assertEquals(2, count($data)); $this->assertEquals('test2', $data[0]['name']); $this->assertEquals('test3', $data[1]['name']); $this->assertEquals(5, $data->total()); - $data = self::$table->index(array('range' => [4, 5], 'sort' => ['id', 'asc'])); + $data = static::$table->select([], ['range' => [4, 5], 'sort' => ['id', 'asc']]); $this->assertEquals(1, count($data)); $this->assertEquals(null, $data[0]['name']); $this->assertEquals(5, $data->total()); } - /** - * @depends testPagination - */ - public function testUpdate() + /** @depends testInsert */ + public function testMultipleSortFields(): void { - $r = self::$table->put(array('name' => 'test1'), array('name' => 'test4')); + $data = static::$table->select([], [ + 'sort' => [ + ['f3', 'desc'], + ['id', 'asc'], + ], + ]); + + $this->assertEquals(5, count($data)); + $this->assertEquals(4, (int) $data[0]['id']); + $this->assertEquals(5, (int) $data[1]['id']); + } + + /** @depends testPagination */ + public function testUpdate(): void + { + $r = static::$table->update(['name' => 'test1'], ['name' => 'test4']); $this->assertEquals(1, $r['updated']); } - /** - * @depends testUpdate - */ - public function testGetCollection() + /** @depends testUpdate */ + public function testGetCollection(): void { - $r = self::$table->get(['filter' => ['name' => 'test4']]); + $r = static::$table->get(['name' => 'test4']); $this->assertNotEmpty($r); $this->assertEquals('2', $r[0]['id']); $this->assertEquals('test4', $r[0]['name']); } - /** - * @depends testUpdate - */ - public function testGetParams() + /** @depends testUpdate */ + public function testGetParams(): void { - $r = self::$table->get(1, array('fields' => 'name')); + $r = static::$table->get(1, ['fields' => 'name']); $this->assertNotEmpty($r); @@ -138,42 +137,35 @@ public function testGetParams() $this->assertEquals('test', $r['name']); } - /** - * @depends testUpdate - */ - public function testUpdateKey() + /** @depends testUpdate */ + public function testUpdateKey(): void { - $r = self::$table->put(3, array('name' => 'test2.1')); + $r = static::$table->update(3, ['name' => 'test2.1']); $this->assertEquals(1, $r['updated']); } - /** - * @depends testUpdateKey - */ - public function testSelectKey() + /** @depends testUpdateKey */ + public function testSelectKey(): void { - $r = self::$table->get(3); + $r = static::$table->get(3); $this->assertEquals('test2.1', $r['name']); } - /** - * @depends testSelectKey - */ - public function testDelete() + /** @depends testSelectKey */ + public function testDelete(): void { - $data = self::$table->index(); + $data = static::$table->select(); $this->assertEquals($data->total(), 5); - $r = self::$table->delete(array('name' => 'test1')); + $r = static::$table->delete(['name' => 'test1']); $this->assertEquals(0, $r); - $r = self::$table->delete(array('name' => 'test4')); + $r = static::$table->delete(['name' => 'test4']); $this->assertEquals(1, $r); - $data = self::$table->index(); + $data = static::$table->select(); $this->assertEquals($data->total(), 4); } - } diff --git a/test/DBCoverageTest.php b/test/DBCoverageTest.php new file mode 100644 index 0000000..d881a3e --- /dev/null +++ b/test/DBCoverageTest.php @@ -0,0 +1,128 @@ +db = DbTestBootstrap::connect(); + DbTestBootstrap::createUsersAndProfiles($this->db); + + $this->db->insert('users', ['name' => 'ann', 'group_name' => 'a', 'is_active' => true]); + $this->db->insert('users', ['name' => 'bob', 'group_name' => 'a', 'is_active' => false]); + $this->db->insert('users', ['name' => 'cara', 'group_name' => 'b', 'is_active' => true]); + + $this->db->insert('profiles', ['user_id' => 1, 'city' => 'NY']); + $this->db->insert('profiles', ['user_id' => 2, 'city' => 'SF']); + } + + public function testTransactionCommitAndRollback(): void + { + $id = $this->db->transaction(function (DB $db) { + return $db->insert('users', ['name' => 'dave', 'group_name' => 'b', 'is_active' => true]); + }); + + $this->assertSame('4', $id); + $this->assertSame(4, $this->db->count('users')); + + try { + $this->db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'rolled', 'group_name' => 'c', 'is_active' => true]); + throw new RuntimeException('boom'); + }); + $this->fail('Expected RuntimeException'); + } catch (RuntimeException $e) { + $this->assertSame('boom', $e->getMessage()); + } + + $this->assertSame(4, $this->db->count('users')); + } + + public function testManualTransactionMethods(): void + { + $this->assertTrue($this->db->beginTransaction()); + $this->db->insert('users', ['name' => 'temp', 'group_name' => 'z', 'is_active' => true]); + $this->assertTrue($this->db->rollBack()); + + $rows = $this->db->select('users', ['name' => 'temp'])->all(); + $this->assertCount(0, $rows); + } + + public function testCountGroupJoinOrderAndLimit(): void + { + $groupCount = $this->db->count('users', null, ['group' => 'group_name']); + $this->assertSame(2, $groupCount); + + $rows = $this->db->select('users', ['is_active' => 1], [ + 'fields' => ['users.id', 'users.name', 'p.city'], + 'join' => ['profiles p' => 'p.user_id = users.id'], + 'order' => ['users.id', 'desc'], + 'limit' => 1, + 'offset' => 0, + ])->all(); + + $this->assertCount(1, $rows); + $this->assertSame('ann', $rows[0]['name']); + $this->assertSame('NY', $rows[0]['city']); + } + + public function testDebugAndHelpers(): void + { + $previousErrorLog = ini_get('error_log'); + ini_set('error_log', '/tmp/objectiveweb-db-test.log'); + + $this->db->debug(true); + $query = $this->db->query('SELECT 1 as ok'); + $this->assertSame('SELECT 1 as ok', $query->debugSql); + $this->db->debug(false); + + $clean = DB::array_cleanup(['a' => 1, 'b' => 2], ['a'], ['z' => 0]); + $this->assertSame(['z' => 0, 'a' => 1], $clean); + + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', DB::now()); + + if (is_string($previousErrorLog)) { + ini_set('error_log', $previousErrorLog); + } + } + + public function testConnectArrayDsnAndInvalidDriver(): void + { + $other = DB::connect(['scheme' => 'sqlite', 'path' => '/:memory:']); + $query = $other->query('SELECT 1 as value'); + $query->exec(); + $row = $query->fetch(); + $this->assertSame('1', (string) $row['value']); + + $this->expectException(InvalidQueryException::class); + DB::connect('unknown:foo=bar'); + } + + public function testUnsafeRawWhereAndJoinAreRejected(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('users', '1=1')->all(); + } + + public function testUnsafeRawJoinIsRejected(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('users', null, [ + 'join' => ['JOIN profiles p ON p.user_id = users.id'], + ])->all(); + } + + public function testTransactionExceptionType(): void + { + $this->assertTrue(is_a(TransactionException::class, \RuntimeException::class, true)); + } +} diff --git a/test/DBTest.php b/test/DBTest.php index d46a5b8..3fed4b1 100644 --- a/test/DBTest.php +++ b/test/DBTest.php @@ -1,62 +1,41 @@ query('drop table if exists db_test')->exec(); - - self::$db->query('create table db_test - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255));')->exec(); + static::$db = DbTestBootstrap::connect(); + DbTestBootstrap::createDbTestTable(static::$db, false); } - public function testInsert() + public function testInsert(): void { - $r = self::$db->insert('db_test', array('name' => 'test')); - - $this->assertEquals(1, $r); - - $r = self::$db->insert('db_test', array('name' => 'test1')); - - $this->assertEquals(2, $r); - - $r = self::$db->insert('db_test', array('name' => 'test2')); + $r = self::$db->insert('db_test', ['name' => 'test']); + $this->assertEquals(1, (int) $r); - $this->assertEquals(3, $r); + $r = self::$db->insert('db_test', ['name' => 'test1']); + $this->assertEquals(2, (int) $r); - $r = self::$db->insert('db_test', array('name' => 'test3')); - - $this->assertEquals(4, $r); - - $r = self::$db->insert('db_test', array('name' => null)); - - $this->assertEquals(5, $r); + $r = self::$db->insert('db_test', ['name' => 'test2']); + $this->assertEquals(3, (int) $r); + $r = self::$db->insert('db_test', ['name' => 'test3']); + $this->assertEquals(4, (int) $r); + $r = self::$db->insert('db_test', ['name' => null]); + $this->assertEquals(5, (int) $r); } - /** - * @depends testInsert - */ - public function testSelectAll() + /** @depends testInsert */ + public function testSelectAll(): void { $rows = self::$db->select('db_test')->all(); @@ -64,35 +43,29 @@ public function testSelectAll() $this->assertEquals('test', $rows[0]['name']); } - /** - * @depends testInsert - */ - public function testSelectParams() + /** @depends testInsert */ + public function testSelectParams(): void { - $r = self::$db->select('db_test', array('name' => 'test3'), array('fields' => array('id')))->all(); + $r = self::$db->select('db_test', ['name' => 'test3'], ['fields' => ['id']])->all(); $this->assertNotEmpty($r); $keys = array_keys($r[0]); $this->assertEquals(1, count($keys)); $this->assertEquals('id', $keys[0]); - $this->assertEquals(4, $r[0]['id']); + $this->assertEquals(4, (int) $r[0]['id']); } - /** - * @depends testInsert - */ - public function testSelectLike() + /** @depends testInsert */ + public function testSelectLike(): void { - $rows = self::$db->select('db_test', array('name' => 'test%'))->all(); + $rows = self::$db->select('db_test', ['name' => 'test%'])->all(); $this->assertEquals(4, count($rows)); $this->assertEquals('test', $rows[0]['name']); } - /** - * @depends testInsert - */ - public function testSelectMap() + /** @depends testInsert */ + public function testSelectMap(): void { $map = self::$db->select('db_test')->map('id'); @@ -104,79 +77,63 @@ public function testSelectMap() $this->assertEquals(null, $map[5]['name']); } - /** - * @depends testInsert - */ - public function testSelectIn() + /** @depends testInsert */ + public function testSelectIn(): void { - $rows = self::$db->select('db_test', array('id' => array(2, 3, 4)))->all(); + $rows = self::$db->select('db_test', ['id' => [2, 3, 4]])->all(); $this->assertEquals(3, count($rows)); - $this->assertEquals(2, $rows[0]['id']); - $this->assertEquals(3, $rows[1]['id']); - $this->assertEquals(4, $rows[2]['id']); + $this->assertEquals(2, (int) $rows[0]['id']); + $this->assertEquals(3, (int) $rows[1]['id']); + $this->assertEquals(4, (int) $rows[2]['id']); } - /** - * @depends testInsert - */ - public function testUpdate() + /** @depends testInsert */ + public function testUpdate(): void { - $r = self::$db->update('db_test', array('name' => 'test4'), array('name' => 'test1')); + $r = self::$db->update('db_test', ['name' => 'test4'], ['name' => 'test1']); $this->assertEquals(1, $r); } - /** - * @depends testUpdate - */ - public function testSelectFetch() + /** @depends testUpdate */ + public function testSelectFetch(): void { - $r = self::$db->select('db_test', array('name' => 'test4'))->fetch(); + $r = self::$db->select('db_test', ['name' => 'test4'])->fetch(); $this->assertEquals('test4', $r['name']); - } - /** - * @depends testUpdate - */ - public function testSelectEmptyResults() + /** @depends testUpdate */ + public function testSelectEmptyResults(): void { - $r = self::$db->select('db_test', array('name' => 'test5'))->all(); + $r = self::$db->select('db_test', ['name' => 'test5'])->all(); $this->assertEmpty(count($r)); - } - /** - * @depends testUpdate - */ - public function testSelectNull() + /** @depends testUpdate */ + public function testSelectNull(): void { - $r = self::$db->select('db_test', array('name' => null))->all(); + $r = self::$db->select('db_test', ['name' => null])->all(); $this->assertEquals(1, count($r)); - - $this->assertEquals(5, $r[0]['id']); + $this->assertEquals(5, (int) $r[0]['id']); } - /** - * @depends testUpdate - */ - public function testDelete() + /** @depends testUpdate */ + public function testDelete(): void { $rows = self::$db->select('db_test')->all(); $this->assertEquals(count($rows), 5); - $r = self::$db->delete('db_test', array('name' => 'test1')); + $r = self::$db->delete('db_test', ['name' => 'test1']); $this->assertEquals(0, $r); - $r = self::$db->delete('db_test', array('name' => 'test4')); + $r = self::$db->delete('db_test', ['name' => 'test4']); $this->assertEquals(1, $r); $rows = self::$db->select('db_test')->all(); $this->assertEquals(count($rows), 4); - } } diff --git a/test/MySQLCompatibilityTest.php b/test/MySQLCompatibilityTest.php new file mode 100644 index 0000000..108da2d --- /dev/null +++ b/test/MySQLCompatibilityTest.php @@ -0,0 +1,43 @@ +markTestSkipped('MYSQL_TEST_DSN not configured.'); + } + + $this->db = DB::connect( + $dsn, + getenv('MYSQL_TEST_USER') ?: 'root', + getenv('MYSQL_TEST_PASSWORD') ?: 'root' + ); + + $this->db->query('DROP TABLE IF EXISTS mysql_compat')->exec(); + $this->db->query('CREATE TABLE mysql_compat (id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT NOT NULL, name VARCHAR(100), score INT)')->exec(); + } + + public function testMysqlCrudFlow(): void + { + $this->db->insert('mysql_compat', ['name' => 'alice', 'score' => 10]); + $this->db->insert('mysql_compat', ['name' => 'bob', 'score' => 20]); + + $this->assertSame(2, $this->db->count('mysql_compat')); + + $this->db->update('mysql_compat', ['score' => 30], ['name' => 'bob']); + $row = $this->db->select('mysql_compat', ['name' => 'bob'])->fetch(); + $this->assertSame('30', (string) $row['score']); + + $this->assertSame(1, $this->db->delete('mysql_compat', ['name' => 'alice'])); + $this->assertSame(1, $this->db->count('mysql_compat')); + } +} diff --git a/test/PgSQLCompatibilityTest.php b/test/PgSQLCompatibilityTest.php new file mode 100644 index 0000000..5318e18 --- /dev/null +++ b/test/PgSQLCompatibilityTest.php @@ -0,0 +1,43 @@ +markTestSkipped('PGSQL_TEST_DSN not configured.'); + } + + $this->db = DB::connect( + $dsn, + getenv('PGSQL_TEST_USER') ?: 'postgres', + getenv('PGSQL_TEST_PASSWORD') ?: 'postgres' + ); + + $this->db->query('DROP TABLE IF EXISTS pgsql_compat')->exec(); + $this->db->query('CREATE TABLE pgsql_compat (id SERIAL PRIMARY KEY, name VARCHAR(100), score INTEGER)')->exec(); + } + + public function testPgsqlCrudFlow(): void + { + $this->db->insert('pgsql_compat', ['name' => 'alice', 'score' => 10]); + $this->db->insert('pgsql_compat', ['name' => 'bob', 'score' => 20]); + + $this->assertSame(2, $this->db->count('pgsql_compat')); + + $this->db->update('pgsql_compat', ['score' => 30], ['name' => 'bob']); + $row = $this->db->select('pgsql_compat', ['name' => 'bob'])->fetch(); + $this->assertSame('30', (string) $row['score']); + + $this->assertSame(1, $this->db->delete('pgsql_compat', ['name' => 'alice'])); + $this->assertSame(1, $this->db->count('pgsql_compat')); + } +} diff --git a/test/QueryAndTableCoverageTest.php b/test/QueryAndTableCoverageTest.php new file mode 100644 index 0000000..d4a1283 --- /dev/null +++ b/test/QueryAndTableCoverageTest.php @@ -0,0 +1,94 @@ +db = DbTestBootstrap::connect(); + DbTestBootstrap::createThings($this->db); + + $this->table = $this->db->table('things'); + $this->table->insert(['name' => 'x1', 'kind' => 'x']); + $this->table->insert(['name' => 'x2', 'kind' => 'x']); + $this->table->insert(['name' => 'x2', 'kind' => 'y']); + $this->table->insert(['name' => 'y1', 'kind' => 'y']); + } + + public function testQueryBindFetchAllAndExecUpdate(): void + { + $query = $this->db->query('SELECT * FROM things WHERE id = :id'); + $query->bind('id', 1)->exec(); + + $first = $query->fetch(); + $this->assertSame('x1', $first['name']); + + $this->assertCount(0, $query->all()); + + $updated = $this->db->query('UPDATE things SET name = :name WHERE id = :id')->exec([ + 'name' => 'x1-updated', + 'id' => 1, + ]); + + $this->assertSame(1, $updated); + $this->assertSame('x1-updated', $this->table->get(1)['name']); + } + + public function testQueryMapInvalidFieldThrows(): void + { + $query = $this->db->select('things'); + + $this->expectException(InvalidQueryException::class); + $query->map('missing_field'); + } + + public function testTableInsertFindByDeleteScalarAndNotFound(): void + { + $inserted = $this->table->insert(['name' => 'z1', 'kind' => 'z']); + $this->assertArrayHasKey('id', $inserted); + + $found = $this->table->findBy('kind', 'x'); + $this->assertSame(2, count($found)); + + $deleted = $this->table->delete(2); + $this->assertSame(1, $deleted); + + $this->expectException(NotFoundException::class); + $this->table->get(9999); + } + + public function testTableGetWithJsonFilterAndRange(): void + { + $data = $this->table->select([], [ + 'filter' => '{"kind":"x"}', + 'range' => '[0,0]', + 'sort' => ['id', 'asc'], + ]); + + $this->assertSame(1, count($data)); + $this->assertSame(2, $data->total()); + } + + public function testSelectParamsFilterMergesWithFirstFilterArgument(): void + { + $data = $this->table->select( + ['name' => 'x2'], + ['filter' => ['kind' => 'x']] + ); + + $this->assertSame(1, count($data)); + $this->assertSame('x2', $data[0]['name']); + $this->assertSame('x', $data[0]['kind']); + } +} diff --git a/test/SQLBehaviorCoverageTest.php b/test/SQLBehaviorCoverageTest.php new file mode 100644 index 0000000..2e980f8 --- /dev/null +++ b/test/SQLBehaviorCoverageTest.php @@ -0,0 +1,134 @@ +db = DbTestBootstrap::connect(); + + $this->db->query('DROP TABLE IF EXISTS item_meta')->exec(); + $this->db->query('DROP TABLE IF EXISTS items')->exec(); + + $this->db->query(sprintf( + 'CREATE TABLE items (%s, name VARCHAR(255), kind VARCHAR(50), score INTEGER, deleted_at VARCHAR(25))', + $this->idDefinition() + ))->exec(); + + $this->db->query(sprintf( + 'CREATE TABLE item_meta (%s, item_id INTEGER, tag VARCHAR(50))', + $this->idDefinition() + ))->exec(); + + $this->db->insert('items', ['name' => 'alpha', 'kind' => 'x', 'score' => 10, 'deleted_at' => null]); + $this->db->insert('items', ['name' => 'beta', 'kind' => 'x', 'score' => 20, 'deleted_at' => '2025-01-01']); + $this->db->insert('items', ['name' => 'gamma', 'kind' => 'y', 'score' => 5, 'deleted_at' => null]); + + $this->db->insert('item_meta', ['item_id' => 1, 'tag' => 't1']); + $this->db->insert('item_meta', ['item_id' => 2, 'tag' => 't2']); + } + + public function testWhereOperatorBranches(): void + { + $notEqual = $this->db->select('items', ['!name' => 'alpha'])->all(); + $this->assertCount(2, $notEqual); + + $notLike = $this->db->select('items', ['!name' => 'a%'])->all(); + $this->assertCount(2, $notLike); + + $isNull = $this->db->select('items', ['deleted_at' => null])->all(); + $this->assertCount(2, $isNull); + + $isNotNull = $this->db->select('items', ['!deleted_at' => null])->all(); + $this->assertCount(1, $isNotNull); + + $emptyIn = $this->db->select('items', ['id' => []])->all(); + $this->assertCount(0, $emptyIn); + + $emptyNotIn = $this->db->select('items', ['!id' => []])->all(); + $this->assertCount(3, $emptyNotIn); + } + + public function testFieldAggregationGroupAndOrderModes(): void + { + $grouped = $this->db->select('items', null, [ + 'fields' => ['kind', 'total' => 'COUNT(*)', 'max_score' => 'MAX(score)'], + 'group' => 'kind', + 'order' => 'kind ASC', + ])->all(); + + $this->assertCount(2, $grouped); + $this->assertSame('x', $grouped[0]['kind']); + $this->assertSame('2', (string) $grouped[0]['total']); + + $onlyTableWildcard = $this->db->select('items', ['id' => 1], [ + 'fields' => ['items.*'], + ])->fetch(); + + $this->assertSame('alpha', $onlyTableWildcard['name']); + } + + public function testJoinVariantsAndOrderList(): void + { + $rows = $this->db->select('items', null, [ + 'fields' => ['items.id', 'items.name', 'm.tag'], + 'join' => ['*item_meta m' => 'm.item_id = items.id'], + 'order' => 'items.id DESC, items.name ASC', + ])->all(); + + $this->assertCount(3, $rows); + $this->assertSame('gamma', $rows[0]['name']); + $this->assertNull($rows[0]['tag']); + } + + public function testMutationSafetyAndInvalidSqlInputs(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->update('items', ['name' => 'unsafe']); + } + + public function testDeleteSafetyAndInvalidClauses(): void + { + try { + $this->db->delete('items', null); + $this->fail('Expected InvalidQueryException for delete without where'); + } catch (InvalidQueryException $e) { + $this->assertStringContainsString('Unsafe DELETE', $e->getMessage()); + } + + $this->expectException(InvalidQueryException::class); + $this->db->select('items', null, ['order' => 'name; DROP TABLE items']); + } + + public function testInvalidGroupAndIdentifierRejection(): void + { + try { + $this->db->select('items', null, ['group' => ['kind']]); + $this->fail('Expected InvalidQueryException for invalid group'); + } catch (InvalidQueryException $e) { + $this->assertStringContainsString('group expects a string field', $e->getMessage()); + } + + $this->expectException(InvalidQueryException::class); + $this->db->select('bad-table')->all(); + } + + private function idDefinition(): string + { + return match ((string) (getenv('TEST_DB_DRIVER') ?: 'sqlite')) { + 'mysql' => 'id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT NOT NULL', + 'pgsql' => 'id SERIAL PRIMARY KEY', + default => 'id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL', + }; + } +} diff --git a/test/Support/DbTestBootstrap.php b/test/Support/DbTestBootstrap.php new file mode 100644 index 0000000..818e8af --- /dev/null +++ b/test/Support/DbTestBootstrap.php @@ -0,0 +1,101 @@ + DB::connect( + getenv('MYSQL_TEST_DSN') ?: sprintf( + 'mysql:dbname=%s;host=%s;port=%s;charset=utf8mb4', + getenv('MYSQL_TEST_DB') ?: 'objectiveweb_test', + getenv('MYSQL_TEST_HOST') ?: '127.0.0.1', + getenv('MYSQL_TEST_PORT') ?: '3306' + ), + getenv('MYSQL_TEST_USER') ?: 'root', + getenv('MYSQL_TEST_PASSWORD') ?: 'root' + ), + 'pgsql' => DB::connect( + getenv('PGSQL_TEST_DSN') ?: sprintf( + 'pgsql:dbname=%s;host=%s;port=%s', + getenv('PGSQL_TEST_DB') ?: 'objectiveweb_test', + getenv('PGSQL_TEST_HOST') ?: '127.0.0.1', + getenv('PGSQL_TEST_PORT') ?: '5432' + ), + getenv('PGSQL_TEST_USER') ?: 'postgres', + getenv('PGSQL_TEST_PASSWORD') ?: 'postgres' + ), + default => DB::connect(getenv('SQLITE_TEST_DSN') ?: 'sqlite::memory:'), + }; + } + + public static function createDbTestTable(DB $db, bool $withExtraFields): void + { + self::dropTable($db, 'db_test'); + + $columns = ['name VARCHAR(255)']; + if ($withExtraFields) { + $columns[] = 'f1 VARCHAR(255)'; + $columns[] = 'f2 VARCHAR(255)'; + $columns[] = 'f3 VARCHAR(255)'; + } + + $db->query(sprintf( + 'CREATE TABLE db_test (%s, %s)', + self::idDefinition(), + implode(', ', $columns) + ))->exec(); + } + + public static function createUsersAndProfiles(DB $db): void + { + self::dropTable($db, 'profiles'); + self::dropTable($db, 'users'); + + $boolType = self::driver() === 'pgsql' ? 'BOOLEAN' : 'INTEGER'; + + $db->query(sprintf( + 'CREATE TABLE users (%s, name VARCHAR(255), group_name VARCHAR(255), is_active %s)', + self::idDefinition(), + $boolType + ))->exec(); + + $db->query(sprintf( + 'CREATE TABLE profiles (%s, user_id INTEGER, city VARCHAR(255))', + self::idDefinition() + ))->exec(); + } + + public static function createThings(DB $db): void + { + self::dropTable($db, 'things'); + + $db->query(sprintf( + 'CREATE TABLE things (%s, name VARCHAR(255), kind VARCHAR(255))', + self::idDefinition() + ))->exec(); + } + + public static function driver(): string + { + return (string) (getenv('TEST_DB_DRIVER') ?: 'sqlite'); + } + + private static function dropTable(DB $db, string $table): void + { + $db->query(sprintf('DROP TABLE IF EXISTS %s', $table))->exec(); + } + + private static function idDefinition(): string + { + return match (self::driver()) { + 'mysql' => 'id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT NOT NULL', + 'pgsql' => 'id SERIAL PRIMARY KEY', + default => 'id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL', + }; + } +} diff --git a/test/TableFromClassTest.php b/test/TableFromClassTest.php index 2f6915e..606bf74 100644 --- a/test/TableFromClassTest.php +++ b/test/TableFromClassTest.php @@ -1,36 +1,27 @@ query('drop table if exists db_test')->exec(); - - $db->query('create table db_test - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255), `f1` VARCHAR(255), `f2` VARCHAR(255), `f3` VARCHAR(255));')->exec(); + $db = DbTestBootstrap::connect(); + DbTestBootstrap::createDbTestTable($db, true); - self::$table = $db->table('DbTestTable'); + static::$table = $db->table(DbTestTable::class); } - public function testClass() + public function testClass(): void { $this->assertInstanceOf(DbTestTable::class, self::$table); } diff --git a/test/TableModelTest.php b/test/TableModelTest.php new file mode 100644 index 0000000..1fe2a1f --- /dev/null +++ b/test/TableModelTest.php @@ -0,0 +1,150 @@ + $data */ + public function __construct(array $data) + { + $this->id = (int) $data['id']; + $this->name = (string) $data['name']; + } +} + +final class TableValidatedModel extends Model +{ + protected static array $validFields = ['name', 'age']; + + protected static array $creationRules = [ + 'name' => [ + 'required' => true, + 'filter' => FILTER_UNSAFE_RAW, + 'validate' => [self::class, 'validateName'], + ], + 'age' => [ + 'required' => true, + 'filter' => FILTER_VALIDATE_INT, + 'validate' => [self::class, 'validateAge'], + ], + ]; + + public static function validateName(mixed $value): bool|string + { + if (!is_string($value) || strlen(trim($value)) < 2) { + return 'name must have at least 2 chars'; + } + + return true; + } + + public static function validateAge(mixed $value): bool|string + { + if (!is_int($value) || $value < 0) { + return 'age must be a non-negative integer'; + } + + return true; + } +} + +class TableModelTest extends TestCase +{ + private DB $db; + + protected function setUp(): void + { + $this->db = DbTestBootstrap::connect(); + + $this->db->query('DROP TABLE IF EXISTS model_test')->exec(); + $this->db->query(sprintf('CREATE TABLE model_test (%s, name VARCHAR(255), age INTEGER)', $this->idDefinition()))->exec(); + + $this->db->insert('model_test', ['name' => 'first', 'age' => 10]); + $this->db->insert('model_test', ['name' => 'second', 'age' => 20]); + } + + public function testTableReturnsModelObjectsUsingConstructorArray(): void + { + $table = $this->db->table('model_test', ['model' => TableModelCtor::class]); + + $rows = $table->select(); + $this->assertInstanceOf(TableModelCtor::class, $rows[0]); + $this->assertSame('first', $rows[0]->name); + + $single = $table->get(2); + $this->assertInstanceOf(TableModelCtor::class, $single); + $this->assertSame(2, $single->id); + } + + public function testInvalidModelClassThrows(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->table('model_test', ['model' => 'NotARealModelClass']); + } + + public function testValidatedModelFiltersAndValidatesOnCreate(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + $id = $table->insert(['name' => 'third', 'age' => 30, 'ignored' => 'x']); + + $this->assertNotNull($id); + + $row = $this->db->select('model_test', ['id' => (int) $id['id']])->fetch(); + $this->assertSame('third', $row['name']); + $this->assertSame('30', (string) $row['age']); + } + + public function testValidatedModelRejectsInvalidCreatePayload(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + + $this->expectException(ModelValidationException::class); + $table->insert(['age' => 30]); + } + + public function testValidatedModelSupportsModelInstancePayload(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + $model = new TableValidatedModel(['name' => 'fourth', 'age' => 44]); + + $id = $table->insert($model); + $this->assertNotNull($id); + } + + public function testValidatedModelRejectsUpdateWithoutValidFields(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + + $this->expectException(ModelValidationException::class); + $table->update(1, ['ignored' => 'x']); + } + + public function testValidatedModelRejectsNegativeAgeWithCustomValidator(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + + $this->expectException(ModelValidationException::class); + $table->insert(['name' => 'bad', 'age' => -1]); + } + + private function idDefinition(): string + { + return match ((string) (getenv('TEST_DB_DRIVER') ?: 'sqlite')) { + 'mysql' => 'id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT NOT NULL', + 'pgsql' => 'id SERIAL PRIMARY KEY', + default => 'id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL', + }; + } +} From 3bbaea4632365815ed1bd773d7145f1428f720a4 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 15 Feb 2026 13:30:20 -0300 Subject: [PATCH 15/24] Update JOIN syntax --- README.md | 44 ++++++++++++ src/DB.php | 120 ++++++++++++++++++++++++++----- test/SQLBehaviorCoverageTest.php | 92 +++++++++++++++++++++++- 3 files changed, 237 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ef6136b..96d6fa1 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,50 @@ $table->insert(['name' => 'Alice']); - `IN`: `['id' => [1, 2, 3]]` - null checks: `['deleted_at' => null]`, `['!deleted_at' => null]` +## Joins + +`select()` accepts joins via `params['join']`. + +### Legacy join map (backward compatible) + +```php +$rows = $db->select('person_links', [], [ + 'join' => [ + 'inner:people p1' => 'p1.id = person_links.parent_a_id', + 'left:people p2' => 'p2.id = person_links.parent_b_id', + 'right:people p3' => 'p3.id = person_links.child_id', + 'full:people p4' => 'p4.id = person_links.child_id', + 'cross:calendar c' => '', + 'festival f' => 'f.id = person_links.child_id', // no prefix => plain JOIN (DB default) + ], +]); +``` + +### Structured join list (recommended) + +```php +$rows = $db->select('person_links', [], [ + 'join' => [ + ['type' => 'inner', 'table' => 'people', 'alias' => 'p1', 'on' => 'p1.id = person_links.parent_a_id'], + ['type' => 'left', 'table' => 'people', 'alias' => 'p2', 'on' => 'p2.id = person_links.parent_b_id'], + ['type' => 'right', 'table' => 'people', 'alias' => 'p3', 'on' => 'p3.id = person_links.child_id'], + ['type' => 'full', 'table' => 'people', 'alias' => 'p4', 'on' => 'p4.id = person_links.child_id'], + ['type' => 'cross', 'table' => 'calendar', 'alias' => 'c'], + ], +]); +``` + +Supported `type` values: +- `inner` +- `left` +- `right` +- `full` (rendered as `FULL OUTER JOIN`) +- `cross` + +Dialect note: +- `RIGHT JOIN` is not supported by SQLite. +- `FULL OUTER JOIN` is PostgreSQL-only in this project test matrix. + ## Notes - Table and field identifiers are validated before SQL generation. diff --git a/src/DB.php b/src/DB.php index 65f7638..a5d2cc5 100644 --- a/src/DB.php +++ b/src/DB.php @@ -438,7 +438,17 @@ private function compileFieldToken(string $field): string } /** - * @param array $join + * Join formats: + * - Legacy map: ['left:table alias' => 'alias.id = base.ref']. + * Supported prefixes: inner:, left:, right:, full:, cross: + * If no prefix is provided, plain JOIN is used (database default join behavior). + * - Structured list: + * [ + * ['type' => 'left', 'table' => 'people', 'alias' => 'p', 'on' => 'p.id = t.person_id'], + * ['type' => 'cross', 'table' => 'calendar', 'alias' => 'c'], + * ] + * + * @param array $join * @return array{0:string,1:array} */ private function compileJoin(array $join, string $baseAlias): array @@ -451,36 +461,110 @@ private function compileJoin(array $join, string $baseAlias): array foreach ($join as $key => $value) { if (is_int($key)) { - throw new InvalidQueryException('Raw JOIN strings are disabled. Use table=>condition syntax.'); - } - - $tableDef = trim((string) $key); - $left = false; + if (!is_array($value)) { + throw new InvalidQueryException('Raw JOIN strings are disabled. Use structured join arrays.'); + } - if ($tableDef !== '' && $tableDef[0] === '*') { - $left = true; - $tableDef = ltrim($tableDef, '*'); + $parts[] = $this->compileStructuredJoin($value); + continue; } - [$table, $alias] = $this->parseTableAlias($tableDef); + [$joinType, $tableDef] = $this->extractLegacyJoinType((string) $key); + [$table, $alias] = $this->parseTableAlias(trim($tableDef)); $condition = trim((string) $value); - if ($condition === '') { + if ($condition === '' && $joinType !== 'CROSS JOIN') { throw new InvalidQueryException('Join condition cannot be empty'); } - $parts[] = sprintf( - '%s JOIN %s %s ON %s', - $left ? 'LEFT' : 'INNER', - $this->quoteIdentifier($this->prefix . $table), - $this->quoteIdentifier($alias), - $condition - ); + $parts[] = $this->renderJoinClause($joinType, $table, $alias, $condition); } unset($baseAlias); return [implode(' ', $parts), []]; } + /** @param array $join */ + private function compileStructuredJoin(array $join): string + { + if (!isset($join['table']) || !is_string($join['table']) || trim($join['table']) === '') { + throw new InvalidQueryException('Structured join requires a non-empty table'); + } + + $joinType = $this->normalizeJoinType($join['type'] ?? 'inner'); + $table = $this->assertIdentifier(trim($join['table'])); + $alias = isset($join['alias']) && is_string($join['alias']) && $join['alias'] !== '' + ? $this->assertIdentifier(trim($join['alias'])) + : $table; + + $condition = isset($join['on']) ? trim((string) $join['on']) : ''; + if ($joinType !== 'CROSS JOIN' && $condition === '') { + throw new InvalidQueryException('Join condition cannot be empty'); + } + + return $this->renderJoinClause($joinType, $table, $alias, $condition); + } + + /** @return array{0:string,1:string} */ + private function extractLegacyJoinType(string $tableDef): array + { + $tableDef = trim($tableDef); + if ($tableDef === '') { + throw new InvalidQueryException('Invalid table definition'); + } + + if (!str_contains($tableDef, ':')) { + return ['JOIN', $tableDef]; + } + + [$type, $remainder] = explode(':', $tableDef, 2); + $type = strtolower(trim($type)); + $remainder = trim($remainder); + + if ($remainder === '') { + throw new InvalidQueryException('Invalid table definition'); + } + + if (!in_array($type, ['inner', 'left', 'right', 'full', 'cross'], true)) { + return ['JOIN', $tableDef]; + } + + return [$this->normalizeJoinType($type), $remainder]; + } + + private function normalizeJoinType(mixed $type): string + { + if (!is_string($type) || trim($type) === '') { + throw new InvalidQueryException('Invalid join type'); + } + + $normalized = strtolower(trim($type)); + return match ($normalized) { + 'inner', 'inner join' => 'INNER JOIN', + 'join' => 'JOIN', + 'left', 'left join', 'left outer', 'left outer join' => 'LEFT JOIN', + 'right', 'right join', 'right outer', 'right outer join' => 'RIGHT JOIN', + 'full', 'full join', 'full outer', 'full outer join' => 'FULL OUTER JOIN', + 'cross', 'cross join' => 'CROSS JOIN', + default => throw new InvalidQueryException('Unsupported join type'), + }; + } + + private function renderJoinClause(string $joinType, string $table, string $alias, string $condition): string + { + $base = sprintf( + '%s %s %s', + $joinType, + $this->quoteIdentifier($this->prefix . $table), + $this->quoteIdentifier($alias) + ); + + if ($joinType === 'CROSS JOIN') { + return $base; + } + + return $base . ' ON ' . $condition; + } + private function compileGroup(mixed $group): string { if (is_array($group)) { diff --git a/test/SQLBehaviorCoverageTest.php b/test/SQLBehaviorCoverageTest.php index 2e980f8..4043b44 100644 --- a/test/SQLBehaviorCoverageTest.php +++ b/test/SQLBehaviorCoverageTest.php @@ -19,6 +19,8 @@ protected function setUp(): void $this->db->query('DROP TABLE IF EXISTS item_meta')->exec(); $this->db->query('DROP TABLE IF EXISTS items')->exec(); + $this->db->query('DROP TABLE IF EXISTS person_links')->exec(); + $this->db->query('DROP TABLE IF EXISTS people')->exec(); $this->db->query(sprintf( 'CREATE TABLE items (%s, name VARCHAR(255), kind VARCHAR(50), score INTEGER, deleted_at VARCHAR(25))', @@ -29,6 +31,14 @@ protected function setUp(): void 'CREATE TABLE item_meta (%s, item_id INTEGER, tag VARCHAR(50))', $this->idDefinition() ))->exec(); + $this->db->query(sprintf( + 'CREATE TABLE people (%s, name VARCHAR(255))', + $this->idDefinition() + ))->exec(); + $this->db->query(sprintf( + 'CREATE TABLE person_links (%s, child_id INTEGER, parent_a_id INTEGER, parent_b_id INTEGER)', + $this->idDefinition() + ))->exec(); $this->db->insert('items', ['name' => 'alpha', 'kind' => 'x', 'score' => 10, 'deleted_at' => null]); $this->db->insert('items', ['name' => 'beta', 'kind' => 'x', 'score' => 20, 'deleted_at' => '2025-01-01']); @@ -36,6 +46,11 @@ protected function setUp(): void $this->db->insert('item_meta', ['item_id' => 1, 'tag' => 't1']); $this->db->insert('item_meta', ['item_id' => 2, 'tag' => 't2']); + + $this->db->insert('people', ['name' => 'child']); + $this->db->insert('people', ['name' => 'parent_a']); + $this->db->insert('people', ['name' => 'parent_b']); + $this->db->insert('person_links', ['child_id' => 1, 'parent_a_id' => 2, 'parent_b_id' => 3]); } public function testWhereOperatorBranches(): void @@ -82,7 +97,7 @@ public function testJoinVariantsAndOrderList(): void { $rows = $this->db->select('items', null, [ 'fields' => ['items.id', 'items.name', 'm.tag'], - 'join' => ['*item_meta m' => 'm.item_id = items.id'], + 'join' => ['left:item_meta m' => 'm.item_id = items.id'], 'order' => 'items.id DESC, items.name ASC', ])->all(); @@ -91,6 +106,76 @@ public function testJoinVariantsAndOrderList(): void $this->assertNull($rows[0]['tag']); } + public function testJoinSameTableTwiceWithDifferentAliases(): void + { + $rows = $this->db->select('person_links', ['person_links.child_id' => 1], [ + 'fields' => [ + 'person_links.child_id', + 'parent_a_name' => 'p1.name', + 'parent_b_name' => 'p2.name', + ], + 'join' => [ + 'inner:people p1' => 'p1.id = person_links.parent_a_id', + 'inner:people p2' => 'p2.id = person_links.parent_b_id', + ], + ])->all(); + + $this->assertCount(1, $rows); + $this->assertSame('parent_a', $rows[0]['parent_a_name']); + $this->assertSame('parent_b', $rows[0]['parent_b_name']); + } + + public function testCrossJoinWithStructuredFormat(): void + { + $rows = $this->db->select('items', ['items.id' => 1], [ + 'fields' => ['item_name' => 'items.name', 'person_name' => 'p.name'], + 'join' => [ + ['type' => 'cross', 'table' => 'people', 'alias' => 'p'], + ], + ])->all(); + + $this->assertCount(3, $rows); + $this->assertSame('alpha', $rows[0]['item_name']); + } + + public function testRightJoinSupport(): void + { + if ($this->driver() === 'sqlite') { + $this->markTestSkipped('RIGHT JOIN is not supported by SQLite.'); + } + + $rows = $this->db->select('people', ['people.name' => 'parent_a'], [ + 'fields' => [ + 'parent_name' => 'people.name', + 'child_name' => 'p2.name', + ], + 'join' => [ + ['type' => 'right', 'table' => 'person_links', 'alias' => 'l', 'on' => 'l.parent_a_id = people.id'], + ['type' => 'inner', 'table' => 'people', 'alias' => 'p2', 'on' => 'p2.id = l.child_id'], + ], + ])->all(); + + $this->assertCount(1, $rows); + $this->assertSame('parent_a', $rows[0]['parent_name']); + $this->assertSame('child', $rows[0]['child_name']); + } + + public function testFullOuterJoinSupport(): void + { + if ($this->driver() !== 'pgsql') { + $this->markTestSkipped('FULL OUTER JOIN test runs on PostgreSQL only.'); + } + + $rows = $this->db->select('people', null, [ + 'fields' => ['people.id', 'p2.id'], + 'join' => [ + ['type' => 'full', 'table' => 'people', 'alias' => 'p2', 'on' => 'p2.id = people.id'], + ], + ])->all(); + + $this->assertCount(3, $rows); + } + public function testMutationSafetyAndInvalidSqlInputs(): void { $this->expectException(InvalidQueryException::class); @@ -131,4 +216,9 @@ private function idDefinition(): string default => 'id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL', }; } + + private function driver(): string + { + return (string) (getenv('TEST_DB_DRIVER') ?: 'sqlite'); + } } From a5e84e554493aee3b93ca3fd5041bceaa51b2871 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 15 Feb 2026 14:10:13 -0300 Subject: [PATCH 16/24] Add SQL functions usage documentation --- README.md | 37 ++++++++++++++++++++++++++++++++ test/SQLBehaviorCoverageTest.php | 14 ++++++++++++ 2 files changed, 51 insertions(+) diff --git a/README.md b/README.md index 96d6fa1..cf3f4d9 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,15 @@ $rows = $db->select('users', ['id' => [1, 2, 3]])->all(); // Select with LIKE $rows = $db->select('users', ['name' => 'Ali%'])->all(); +// Select with SQL functions +$stats = $db->select('users', [], [ + 'fields' => [ + 'total' => 'COUNT(*)', + 'avg_age' => 'AVG(age)', + 'max_age' => 'MAX(age)', + ], +])->fetch(); + // Map by field $byId = $db->select('users')->map('id'); @@ -170,6 +179,34 @@ $table->insert(['name' => 'Alice']); - `IN`: `['id' => [1, 2, 3]]` - null checks: `['deleted_at' => null]`, `['!deleted_at' => null]` +## SQL function fields + +You can use SQL functions in `params['fields']` and alias them with array keys. + +```php +$row = $db->select('orders', ['status' => 'paid'], [ + 'fields' => [ + 'total_orders' => 'COUNT(*)', + 'avg_total' => 'AVG(total)', + 'max_total' => 'MAX(total)', + ], +])->fetch(); +``` + +Grouped aggregate example: + +```php +$rows = $db->select('orders', null, [ + 'fields' => [ + 'customer_id', + 'orders_count' => 'COUNT(*)', + 'avg_total' => 'AVG(total)', + ], + 'group' => 'customer_id', + 'order' => 'customer_id ASC', +])->all(); +``` + ## Joins `select()` accepts joins via `params['join']`. diff --git a/test/SQLBehaviorCoverageTest.php b/test/SQLBehaviorCoverageTest.php index 4043b44..cde1bfb 100644 --- a/test/SQLBehaviorCoverageTest.php +++ b/test/SQLBehaviorCoverageTest.php @@ -93,6 +93,20 @@ public function testFieldAggregationGroupAndOrderModes(): void $this->assertSame('alpha', $onlyTableWildcard['name']); } + public function testSelectSupportsCountAndAvgFunctionFields(): void + { + $row = $this->db->select('items', ['kind' => 'x'], [ + 'fields' => [ + 'total' => 'COUNT(*)', + 'avg_score' => 'AVG(score)', + ], + ])->fetch(); + + $this->assertNotFalse($row); + $this->assertSame('2', (string) $row['total']); + $this->assertEqualsWithDelta(15.0, (float) $row['avg_score'], 0.00001); + } + public function testJoinVariantsAndOrderList(): void { $rows = $this->db->select('items', null, [ From a356176b26400d24380340bfb6c8c6d9ce7fd78e Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 15 Feb 2026 16:04:42 -0300 Subject: [PATCH 17/24] Use the DB constructor instead of DB::connect() --- README.md | 10 ++++++++-- src/DB.php | 17 +++-------------- test/DBCoverageTest.php | 7 ++----- test/MySQLCompatibilityTest.php | 2 +- test/PgSQLCompatibilityTest.php | 2 +- test/Support/DbTestBootstrap.php | 6 +++--- 6 files changed, 18 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index cf3f4d9..ea51d10 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,13 @@ composer require objectiveweb/db doctrine/dbal ```php use Objectiveweb\DB; -$db = DB::connect('mysql:dbname=app;host=127.0.0.1', 'user', 'secret'); +$db = new DB('mysql:dbname=app;host=127.0.0.1', 'user', 'secret', [ + 'prefix' => 'myprefix_', +]); +``` + +```php +use Objectiveweb\DB; // Raw query $db->query('CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255))')->exec(); @@ -68,7 +74,7 @@ $db->transaction(function (DB $db) { ```php use Objectiveweb\DB; -$db = DB::connect('mysql:dbname=app;host=127.0.0.1', 'user', 'secret'); +$db = new DB('mysql:dbname=app;host=127.0.0.1', 'user', 'secret'); $table = $db->table('users', [ 'pk' => 'id', diff --git a/src/DB.php b/src/DB.php index a5d2cc5..0c1ffae 100644 --- a/src/DB.php +++ b/src/DB.php @@ -20,21 +20,10 @@ class DB private string $prefix; - public function __construct(Connection $connection, ?string $prefix = null) + public function __construct(string|array $dsn, ?string $username = null, string $password = '', array $options = []) { - $this->connection = $connection; - $this->prefix = $prefix ?? ''; - } - /** - * Creates a DB instance from DSN or parsed-url array. - * - * @param string|array $dsn - * @param array $options - */ - public static function connect(string|array $dsn, ?string $username = null, string $password = '', array $options = []): self - { - $prefix = isset($options['prefix']) ? (string) $options['prefix'] : ''; + $this->prefix = isset($options['prefix']) ? (string) $options['prefix'] : ''; unset($options['prefix']); if (is_array($dsn)) { @@ -43,7 +32,7 @@ public static function connect(string|array $dsn, ?string $username = null, stri $params = self::fromDsnString($dsn, $username, $password, $options); } - return new self(DriverManager::getConnection($params), $prefix); + $this->connection = DriverManager::getConnection($params); } public function query(string $sql, mixed ...$args): Query diff --git a/test/DBCoverageTest.php b/test/DBCoverageTest.php index d881a3e..b961b59 100644 --- a/test/DBCoverageTest.php +++ b/test/DBCoverageTest.php @@ -95,16 +95,13 @@ public function testDebugAndHelpers(): void } } - public function testConnectArrayDsnAndInvalidDriver(): void + public function testConstructorWithParsedDsnArray(): void { - $other = DB::connect(['scheme' => 'sqlite', 'path' => '/:memory:']); + $other = new DB(['scheme' => 'sqlite', 'path' => '/:memory:']); $query = $other->query('SELECT 1 as value'); $query->exec(); $row = $query->fetch(); $this->assertSame('1', (string) $row['value']); - - $this->expectException(InvalidQueryException::class); - DB::connect('unknown:foo=bar'); } public function testUnsafeRawWhereAndJoinAreRejected(): void diff --git a/test/MySQLCompatibilityTest.php b/test/MySQLCompatibilityTest.php index 108da2d..5a28309 100644 --- a/test/MySQLCompatibilityTest.php +++ b/test/MySQLCompatibilityTest.php @@ -16,7 +16,7 @@ protected function setUp(): void $this->markTestSkipped('MYSQL_TEST_DSN not configured.'); } - $this->db = DB::connect( + $this->db = new DB( $dsn, getenv('MYSQL_TEST_USER') ?: 'root', getenv('MYSQL_TEST_PASSWORD') ?: 'root' diff --git a/test/PgSQLCompatibilityTest.php b/test/PgSQLCompatibilityTest.php index 5318e18..8cdedf9 100644 --- a/test/PgSQLCompatibilityTest.php +++ b/test/PgSQLCompatibilityTest.php @@ -16,7 +16,7 @@ protected function setUp(): void $this->markTestSkipped('PGSQL_TEST_DSN not configured.'); } - $this->db = DB::connect( + $this->db = new DB( $dsn, getenv('PGSQL_TEST_USER') ?: 'postgres', getenv('PGSQL_TEST_PASSWORD') ?: 'postgres' diff --git a/test/Support/DbTestBootstrap.php b/test/Support/DbTestBootstrap.php index 818e8af..e70a91c 100644 --- a/test/Support/DbTestBootstrap.php +++ b/test/Support/DbTestBootstrap.php @@ -9,7 +9,7 @@ final class DbTestBootstrap public static function connect(): DB { return match (self::driver()) { - 'mysql' => DB::connect( + 'mysql' => new DB( getenv('MYSQL_TEST_DSN') ?: sprintf( 'mysql:dbname=%s;host=%s;port=%s;charset=utf8mb4', getenv('MYSQL_TEST_DB') ?: 'objectiveweb_test', @@ -19,7 +19,7 @@ public static function connect(): DB getenv('MYSQL_TEST_USER') ?: 'root', getenv('MYSQL_TEST_PASSWORD') ?: 'root' ), - 'pgsql' => DB::connect( + 'pgsql' => new DB( getenv('PGSQL_TEST_DSN') ?: sprintf( 'pgsql:dbname=%s;host=%s;port=%s', getenv('PGSQL_TEST_DB') ?: 'objectiveweb_test', @@ -29,7 +29,7 @@ public static function connect(): DB getenv('PGSQL_TEST_USER') ?: 'postgres', getenv('PGSQL_TEST_PASSWORD') ?: 'postgres' ), - default => DB::connect(getenv('SQLITE_TEST_DSN') ?: 'sqlite::memory:'), + default => new DB(getenv('SQLITE_TEST_DSN') ?: 'sqlite::memory:'), }; } From aaeda47be366ca457e306f3c99565bff7e500a5c Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 15 Feb 2026 16:05:06 -0300 Subject: [PATCH 18/24] Support dotted fields in responses --- src/DB.php | 43 +++++++++++++++++++- test/SQLBehaviorCoverageTest.php | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/DB.php b/src/DB.php index 0c1ffae..9270472 100644 --- a/src/DB.php +++ b/src/DB.php @@ -392,7 +392,7 @@ private function compileFields(array|string $fields): array $rendered = $this->compileFieldToken($field); if (!is_int($alias)) { - $rendered .= ' AS ' . $this->quoteIdentifier($this->assertIdentifier((string) $alias)); + $rendered .= ' AS ' . $this->quoteSingleIdentifier($this->assertIdentifier((string) $alias)); } $compiled[] = $rendered; } @@ -423,6 +423,42 @@ private function compileFieldToken(string $field): string return sprintf('%s(%s)', $function, $target); } + if (preg_match( + '/^COUNT\(\s*CASE\s+WHEN\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s+IS\s+(NOT\s+)?NULL\s+THEN\s+(-?\d+)(?:\s+ELSE\s+(-?\d+))?\s+END\s*\)$/i', + $field, + $matches + ) === 1) { + $target = $this->quoteIdentifierPath($this->assertIdentifier($matches[1])); + $not = isset($matches[2]) && trim($matches[2]) !== '' ? 'NOT ' : ''; + $thenValue = $matches[3]; + $elseClause = isset($matches[4]) && $matches[4] !== '' ? ' ELSE ' . $matches[4] : ''; + + return sprintf( + 'COUNT(CASE WHEN %s IS %sNULL THEN %s%s END)', + $target, + $not, + $thenValue, + $elseClause + ); + } + + if (preg_match('/^COALESCE\((.+)\)$/i', $field, $matches) === 1) { + $arguments = array_map('trim', explode(',', $matches[1])); + if (count($arguments) < 2) { + throw new InvalidQueryException('COALESCE expects at least two identifiers'); + } + + $quoted = []; + foreach ($arguments as $argument) { + if ($argument === '') { + throw new InvalidQueryException('COALESCE expects valid identifier arguments'); + } + $quoted[] = $this->quoteIdentifierPath($this->assertIdentifier($argument)); + } + + return sprintf('COALESCE(%s)', implode(', ', $quoted)); + } + return $this->quoteIdentifierPath($this->assertIdentifier($field)); } @@ -674,6 +710,11 @@ private function quoteIdentifier(string $identifier): string return $this->connection->quoteIdentifier($identifier); } + private function quoteSingleIdentifier(string $identifier): string + { + return $this->connection->quoteSingleIdentifier($identifier); + } + /** * @param array $dsn * @param array $options diff --git a/test/SQLBehaviorCoverageTest.php b/test/SQLBehaviorCoverageTest.php index cde1bfb..abdcc3b 100644 --- a/test/SQLBehaviorCoverageTest.php +++ b/test/SQLBehaviorCoverageTest.php @@ -107,6 +107,73 @@ public function testSelectSupportsCountAndAvgFunctionFields(): void $this->assertEqualsWithDelta(15.0, (float) $row['avg_score'], 0.00001); } + public function testSelectSupportsCountCaseWhenFunctionFields(): void + { + $row = $this->db->select('items', null, [ + 'fields' => [ + 'activated_count' => 'COUNT(CASE WHEN items.deleted_at IS NOT NULL THEN 1 END)', + 'inactive_score_count' => 'COUNT(CASE WHEN items.score IS NULL THEN 1 END)', + ], + ])->fetch(); + + $this->assertNotFalse($row); + $this->assertSame('1', (string) $row['activated_count']); + $this->assertSame('0', (string) $row['inactive_score_count']); + } + + public function testSelectSupportsCoalesceFunctionFields(): void + { + $rows = $this->db->select('items', null, [ + 'fields' => [ + 'items.id', + 'display_tag' => 'coalesce(m.tag, items.kind)', + ], + 'join' => ['left:item_meta m' => 'm.item_id = items.id'], + 'order' => 'items.id ASC', + ])->all(); + + $this->assertCount(3, $rows); + $this->assertSame('t1', $rows[0]['display_tag']); + $this->assertSame('t2', $rows[1]['display_tag']); + $this->assertSame('y', $rows[2]['display_tag']); + } + + public function testSelectSupportsDottedAliases(): void + { + $row = $this->db->select('items', ['items.id' => 1], [ + 'fields' => [ + 'item.name' => 'items.name', + 'item.kind' => 'items.kind', + ], + ])->fetch(); + + $this->assertNotFalse($row); + $this->assertArrayHasKey('item.name', $row); + $this->assertArrayHasKey('item.kind', $row); + $this->assertSame('alpha', $row['item.name']); + $this->assertSame('x', $row['item.kind']); + } + + public function testSelectRejectsInvalidCountCaseWhenFunctionFields(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('items', null, [ + 'fields' => [ + 'bad' => 'COUNT(CASE WHEN items.deleted_at IS NOT NULL THEN END)', + ], + ])->all(); + } + + public function testSelectRejectsInvalidCoalesceFunctionFields(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('items', null, [ + 'fields' => [ + 'bad' => 'coalesce(items.name)', + ], + ])->all(); + } + public function testJoinVariantsAndOrderList(): void { $rows = $this->db->select('items', null, [ From 5a26391d8bdbc30e7a4dd9463a8d592eddf9c44e Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 15 Feb 2026 16:44:34 -0300 Subject: [PATCH 19/24] fix: prepend table name when searching with pk --- src/DB/Table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DB/Table.php b/src/DB/Table.php index 6b74492..68c0cbf 100644 --- a/src/DB/Table.php +++ b/src/DB/Table.php @@ -122,7 +122,7 @@ public function get(mixed $key = null, array $params = []): mixed } $params = $this->parseParams($params); - $where = [(string) $this->params['pk'] => $key]; + $where = ["{$this->table}.{$this->params['pk']}" => $key]; $query = $this->db->select((string) $this->table, $where, array_merge($params, ['limit' => 1])); $record = $query->fetch(); From dc5ceaa7a9b3a8d1e98ca0081d15ff7204301603 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Mon, 16 Feb 2026 09:55:14 -0300 Subject: [PATCH 20/24] Add tests for nested transactions --- test/DBCoverageTest.php | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/DBCoverageTest.php b/test/DBCoverageTest.php index b961b59..a9aa0c5 100644 --- a/test/DBCoverageTest.php +++ b/test/DBCoverageTest.php @@ -47,6 +47,65 @@ public function testTransactionCommitAndRollback(): void $this->assertSame(4, $this->db->count('users')); } + public function testNestedTransactionsCommit(): void + { + $this->db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'outer-ok', 'group_name' => 'n', 'is_active' => true]); + + $db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'inner-ok', 'group_name' => 'n', 'is_active' => true]); + }); + }); + + $this->assertSame(5, $this->db->count('users')); + $this->assertSame(1, $this->db->count('users', ['name' => 'outer-ok'])); + $this->assertSame(1, $this->db->count('users', ['name' => 'inner-ok'])); + } + + public function testNestedTransactionsInnerRollbackAndOuterCommit(): void + { + $this->db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'outer-before', 'group_name' => 'n', 'is_active' => true]); + + try { + $db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'inner-fail', 'group_name' => 'n', 'is_active' => true]); + throw new RuntimeException('inner failure'); + }); + } catch (RuntimeException $e) { + $this->assertSame('inner failure', $e->getMessage()); + } + + $db->insert('users', ['name' => 'outer-after', 'group_name' => 'n', 'is_active' => true]); + }); + + $this->assertSame(5, $this->db->count('users')); + $this->assertSame(1, $this->db->count('users', ['name' => 'outer-before'])); + $this->assertSame(0, $this->db->count('users', ['name' => 'inner-fail'])); + $this->assertSame(1, $this->db->count('users', ['name' => 'outer-after'])); + } + + public function testNestedTransactionsUncaughtInnerErrorRollsBackOuter(): void + { + try { + $this->db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'outer-uncaught', 'group_name' => 'n', 'is_active' => true]); + + $db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'inner-uncaught', 'group_name' => 'n', 'is_active' => true]); + throw new RuntimeException('nested uncaught'); + }); + }); + $this->fail('Expected RuntimeException'); + } catch (RuntimeException $e) { + $this->assertSame('nested uncaught', $e->getMessage()); + } + + $this->assertSame(3, $this->db->count('users')); + $this->assertSame(0, $this->db->count('users', ['name' => 'outer-uncaught'])); + $this->assertSame(0, $this->db->count('users', ['name' => 'inner-uncaught'])); + } + public function testManualTransactionMethods(): void { $this->assertTrue($this->db->beginTransaction()); From f9a01837b7c62f6420538201235a59af021000a9 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 22 Feb 2026 20:27:15 -0300 Subject: [PATCH 21/24] feat: Collection Supports By Reference Iteration --- src/DB/Collection.php | 4 ++-- test/CrudTest.php | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/DB/Collection.php b/src/DB/Collection.php index 4aab0a6..9e3db6f 100644 --- a/src/DB/Collection.php +++ b/src/DB/Collection.php @@ -83,9 +83,9 @@ public function count(): int return count($this->data); } - public function getIterator(): \Traversable + public function &getIterator(): \Traversable { - foreach ($this->data as $key => $val) { + foreach ($this->data as $key => &$val) { yield $key => $val; } } diff --git a/test/CrudTest.php b/test/CrudTest.php index 22d45b3..e34c9f1 100644 --- a/test/CrudTest.php +++ b/test/CrudTest.php @@ -57,6 +57,20 @@ public function testIndex(): void $this->assertEquals(5, $count); } + /** @depends testInsert */ + public function testCollectionSupportsByReferenceIteration(): void + { + $rows = static::$table->select([], ['sort' => ['id', 'asc']]); + + foreach ($rows as &$row) { + $row['name'] = 'mutated-' . ((string) $row['id']); + } + unset($row); + + $this->assertSame('mutated-1', $rows[0]['name']); + $this->assertSame('mutated-2', $rows[1]['name']); + } + /** @depends testInsert */ public function testFields(): void { From ff90a3a32b679526ea43e7d1c11ce00690acad39 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 22 Feb 2026 20:29:20 -0300 Subject: [PATCH 22/24] fix: Order and Group By syntax --- src/DB.php | 41 +++++++++++++++++++++++++----- test/QueryAndTableCoverageTest.php | 11 ++++++++ test/SQLBehaviorCoverageTest.php | 4 +-- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/DB.php b/src/DB.php index 9270472..a1e76b8 100644 --- a/src/DB.php +++ b/src/DB.php @@ -592,15 +592,31 @@ private function renderJoinClause(string $joinType, string $table, string $alias private function compileGroup(mixed $group): string { - if (is_array($group)) { - throw new InvalidQueryException('group expects a string field'); + if (is_string($group)) { + $parts = array_values(array_filter(array_map('trim', explode(',', $group)), fn (string $part): bool => $part !== '')); + } elseif (is_array($group)) { + $parts = []; + foreach ($group as $part) { + if (!is_string($part) || trim($part) === '') { + throw new InvalidQueryException('Invalid group value'); + } + + $parts[] = trim($part); + } + } else { + throw new InvalidQueryException('Invalid group value'); } - if (!is_string($group) || trim($group) === '') { + if ($parts === []) { throw new InvalidQueryException('Invalid group value'); } - return $this->quoteIdentifierPath($this->assertIdentifier(trim($group))); + $compiled = array_map( + fn (string $part): string => $this->quoteIdentifierPath($this->assertIdentifier($part)), + $parts + ); + + return implode(', ', $compiled); } private function compileOrder(mixed $order): string @@ -665,14 +681,25 @@ private function compileOrder(mixed $order): string private function compileOrderPiece(string $piece): string { - if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)(?:\s+(ASC|DESC))?$/i', trim($piece), $matches) !== 1) { + $trimmed = trim($piece); + + if (preg_match( + '/^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)(?:\s+IS\s+(NOT\s+)?NULL)?(?:\s+(ASC|DESC))?$/i', + $trimmed, + $matches + ) !== 1) { throw new InvalidQueryException('Invalid order expression'); } $field = $this->quoteIdentifierPath($this->assertIdentifier($matches[1])); - $dir = isset($matches[2]) ? ' ' . strtoupper($matches[2]) : ''; + $nullClause = ''; + if (stripos($trimmed, ' IS ') !== false) { + $nullClause = isset($matches[2]) && trim((string) $matches[2]) !== '' ? ' IS NOT NULL' : ' IS NULL'; + } + + $dir = isset($matches[3]) ? ' ' . strtoupper($matches[3]) : ''; - return $field . $dir; + return $field . $nullClause . $dir; } /** @return array{0:string,1:string} */ diff --git a/test/QueryAndTableCoverageTest.php b/test/QueryAndTableCoverageTest.php index d4a1283..1047697 100644 --- a/test/QueryAndTableCoverageTest.php +++ b/test/QueryAndTableCoverageTest.php @@ -91,4 +91,15 @@ public function testSelectParamsFilterMergesWithFirstFilterArgument(): void $this->assertSame('x2', $data[0]['name']); $this->assertSame('x', $data[0]['kind']); } + + public function testOrderSupportsIsNullExpression(): void + { + $this->table->insert(['name' => null, 'kind' => 'z']); + + $rows = $this->db->select('things', null, [ + 'order' => ['name is null desc', 'id asc'], + ])->all(); + + $this->assertNull($rows[0]['name']); + } } diff --git a/test/SQLBehaviorCoverageTest.php b/test/SQLBehaviorCoverageTest.php index abdcc3b..82d380f 100644 --- a/test/SQLBehaviorCoverageTest.php +++ b/test/SQLBehaviorCoverageTest.php @@ -279,10 +279,10 @@ public function testDeleteSafetyAndInvalidClauses(): void public function testInvalidGroupAndIdentifierRejection(): void { try { - $this->db->select('items', null, ['group' => ['kind']]); + $this->db->select('items', null, ['group' => ['kind', '']]); $this->fail('Expected InvalidQueryException for invalid group'); } catch (InvalidQueryException $e) { - $this->assertStringContainsString('group expects a string field', $e->getMessage()); + $this->assertStringContainsString('Invalid group value', $e->getMessage()); } $this->expectException(InvalidQueryException::class); From e9a1069a19015ed1af21791128ed2dc37eb9a9c9 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 22 Feb 2026 20:29:52 -0300 Subject: [PATCH 23/24] feat: Add DB\Expr --- src/DB.php | 25 ++++++++++++++++++++++--- src/DB/Expr.php | 22 ++++++++++++++++++++++ test/DBCoverageTest.php | 23 +++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/DB/Expr.php diff --git a/src/DB.php b/src/DB.php index a1e76b8..f1a9589 100644 --- a/src/DB.php +++ b/src/DB.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; +use Objectiveweb\DB\Expr; use Objectiveweb\DB\Exception\InvalidQueryException; use Objectiveweb\DB\Exception\TransactionException; use Objectiveweb\DB\Query; @@ -379,14 +380,14 @@ private function buildWhereClause(array|string|null $args = null, string $glue = return [implode(' ' . $glue . ' ', $cond), $bindings]; } - /** @param list|string $fields */ + /** @param list|string $fields */ private function compileFields(array|string $fields): array { $fields = is_array($fields) ? $fields : array_map('trim', explode(',', $fields)); $compiled = []; foreach ($fields as $alias => $field) { - if (!is_string($field) || $field === '') { + if (!is_string($field) && !$field instanceof Expr) { throw new InvalidQueryException('Invalid SELECT field'); } @@ -404,8 +405,16 @@ private function compileFields(array|string $fields): array return $compiled; } - private function compileFieldToken(string $field): string + private function compileFieldToken(string|Expr $field): string { + if ($field instanceof Expr) { + $sql = trim($field->toSql()); + if ($sql === '') { + throw new InvalidQueryException('Invalid SELECT expression'); + } + return $sql; + } + $field = trim($field); if ($field === '*') { @@ -423,6 +432,16 @@ private function compileFieldToken(string $field): string return sprintf('%s(%s)', $function, $target); } + if (preg_match( + '/^GROUP_CONCAT\(\s*(DISTINCT\s+)?([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s*\)$/i', + $field, + $matches + ) === 1) { + $distinct = isset($matches[1]) && trim($matches[1]) !== '' ? 'DISTINCT ' : ''; + $target = $this->quoteIdentifierPath($this->assertIdentifier($matches[2])); + return sprintf('GROUP_CONCAT(%s%s)', $distinct, $target); + } + if (preg_match( '/^COUNT\(\s*CASE\s+WHEN\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s+IS\s+(NOT\s+)?NULL\s+THEN\s+(-?\d+)(?:\s+ELSE\s+(-?\d+))?\s+END\s*\)$/i', $field, diff --git a/src/DB/Expr.php b/src/DB/Expr.php new file mode 100644 index 0000000..95e6149 --- /dev/null +++ b/src/DB/Expr.php @@ -0,0 +1,22 @@ +sql; + } +} diff --git a/test/DBCoverageTest.php b/test/DBCoverageTest.php index a9aa0c5..40ab9e3 100644 --- a/test/DBCoverageTest.php +++ b/test/DBCoverageTest.php @@ -4,6 +4,7 @@ require_once __DIR__ . '/Support/DbTestBootstrap.php'; use Objectiveweb\DB; +use Objectiveweb\DB\Expr; use Objectiveweb\DB\Exception\InvalidQueryException; use Objectiveweb\DB\Exception\TransactionException; use PHPUnit\Framework\TestCase; @@ -134,6 +135,28 @@ public function testCountGroupJoinOrderAndLimit(): void $this->assertSame('NY', $rows[0]['city']); } + public function testSelectSupportsExprFieldWithAlias(): void + { + $rows = $this->db->select('users', ['name' => 'ann'], [ + 'fields' => [ + 'users.id', + 'is_ann' => Expr::raw("CASE WHEN users.name = 'ann' THEN 1 ELSE 0 END"), + ], + ])->all(); + + $this->assertCount(1, $rows); + $this->assertSame('1', (string)$rows[0]['is_ann']); + } + + public function testGroupSupportsMultipleFieldsAsStringAndArray(): void + { + $countFromString = $this->db->count('users', null, ['group' => 'group_name, is_active']); + $this->assertSame(3, $countFromString); + + $countFromArray = $this->db->count('users', null, ['group' => ['group_name', 'is_active']]); + $this->assertSame(3, $countFromArray); + } + public function testDebugAndHelpers(): void { $previousErrorLog = ini_get('error_log'); From a8daaf7bfed8e20a4eee1e229791f10875138e23 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Mon, 23 Feb 2026 21:20:21 -0300 Subject: [PATCH 24/24] feat: Support locks (FOR SHARE, FOR UPDATE) --- README.md | 20 ++++++++++++++++++++ src/DB.php | 27 ++++++++++++++++++++++++++- test/DBCoverageTest.php | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ea51d10..9be771e 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,26 @@ Dialect note: - `RIGHT JOIN` is not supported by SQLite. - `FULL OUTER JOIN` is PostgreSQL-only in this project test matrix. +## Row locking + +`select()` supports row-level locks via `params['lock']`. + +```php +$row = $db->select('users', ['id' => 1], [ + 'lock' => 'update', // or true + 'limit' => 1, +])->fetch(); +``` + +Supported values: +- `true`, `'update'`, `'for update'` => `FOR UPDATE` +- `'share'`, `'for share'` => `FOR SHARE` +- `null`, `false`, `''` => no lock clause + +Notes: +- SQLite does not support `FOR UPDATE`/`FOR SHARE`. +- Invalid lock values throw `Objectiveweb\DB\Exception\InvalidQueryException`. + ## Notes - Table and field identifiers are validated before SQL generation. diff --git a/src/DB.php b/src/DB.php index f1a9589..88bbafd 100644 --- a/src/DB.php +++ b/src/DB.php @@ -43,9 +43,9 @@ public function query(string $sql, mixed ...$args): Query } $query = new Query($this->connection, $sql); + $query->debugSql = $sql; if ($this->debug) { - $query->debugSql = $sql; error_log($sql); } @@ -114,6 +114,7 @@ public function select(string $table, array|string|null $where = null, array $pa 'limit' => null, 'offset' => 0, 'join' => [], + 'lock' => null, ]; $params = array_merge($defaults, $params); @@ -146,6 +147,8 @@ public function select(string $table, array|string|null $where = null, array $pa $sql .= sprintf(' LIMIT %d OFFSET %d', (int) $params['limit'], (int) $params['offset']); } + $sql .= $this->compileLockClause($params['lock'] ?? null); + $query = $this->query($sql); $query->exec(array_merge($joinBindings, $whereBindings)); @@ -638,6 +641,28 @@ private function compileGroup(mixed $group): string return implode(', ', $compiled); } + private function compileLockClause(mixed $lock): string + { + if ($lock === null || $lock === false || $lock === '') { + return ''; + } + + if ($lock === true) { + return ' FOR UPDATE'; + } + + if (!is_string($lock)) { + throw new InvalidQueryException('Invalid lock clause'); + } + + $normalized = strtolower(trim($lock)); + return match ($normalized) { + 'update', 'for update' => ' FOR UPDATE', + 'share', 'for share' => ' FOR SHARE', + default => throw new InvalidQueryException('Unsupported lock mode'), + }; + } + private function compileOrder(mixed $order): string { if (is_string($order)) { diff --git a/test/DBCoverageTest.php b/test/DBCoverageTest.php index 40ab9e3..7d00fc7 100644 --- a/test/DBCoverageTest.php +++ b/test/DBCoverageTest.php @@ -148,6 +148,39 @@ public function testSelectSupportsExprFieldWithAlias(): void $this->assertSame('1', (string)$rows[0]['is_ann']); } + public function testSelectSupportsForUpdateLock(): void + { + if (DbTestBootstrap::driver() === 'sqlite') { + $this->markTestSkipped('SQLite does not support FOR UPDATE.'); + } + + $query = $this->db->select('users', ['id' => 1], [ + 'fields' => ['users.id', 'users.name'], + 'lock' => 'update', + 'limit' => 1, + ]); + + $row = $query->fetch(); + $this->assertSame('ann', $row['name']); + $this->assertStringContainsString('FOR UPDATE', (string)$query->debugSql); + } + + public function testSelectSupportsBooleanLockAlias(): void + { + if (DbTestBootstrap::driver() === 'sqlite') { + $this->markTestSkipped('SQLite does not support FOR UPDATE.'); + } + + $query = $this->db->select('users', ['id' => 1], [ + 'fields' => ['users.id'], + 'lock' => true, + 'limit' => 1, + ]); + + $this->assertNotFalse($query->fetch()); + $this->assertStringContainsString('FOR UPDATE', (string)$query->debugSql); + } + public function testGroupSupportsMultipleFieldsAsStringAndArray(): void { $countFromString = $this->db->count('users', null, ['group' => 'group_name, is_active']); @@ -192,6 +225,14 @@ public function testUnsafeRawWhereAndJoinAreRejected(): void $this->db->select('users', '1=1')->all(); } + public function testInvalidLockModeIsRejected(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('users', null, [ + 'lock' => 'invalid_lock', + ])->all(); + } + public function testUnsafeRawJoinIsRejected(): void { $this->expectException(InvalidQueryException::class);