Pārlūkot izejas kodu

Initial commit of PHP SQL Mapper

michelphp 1 dienu atpakaļ
revīzija
ea20842a3a

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+/vendor/
+/.idea/
+composer.lock

+ 408 - 0
README.md

@@ -0,0 +1,408 @@
+# PHP SQL Mapper
+
+A powerful PHP library that not only builds complex SQL queries but also maps the results into structured object graphs. It simplifies handling relational data by automatically transforming flat result sets from joins into nested arrays, making it ideal for working with one-to-one and one-to-many relationships.
+
+## Installation
+
+You can install this library via [Composer](https://getcomposer.org/). Make sure your project meets the minimum PHP version requirement of 7.4 or higher.
+
+```bash
+composer require michel/php-sql-mapper
+```
+
+## Usage
+
+The SQL Query Builder library allows you to build SQL queries fluently using an object-oriented approach. Here are some examples of usage:
+
+### Creating a SELECT Query
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create a SELECT query
+$query = QueryBuilder::select('name', 'email')
+    ->from('users')
+    ->where('status = "active"')
+    ->orderBy('name')
+    ->limit(10);
+
+echo $query; // Outputs: SELECT name, email FROM users WHERE status = "active" ORDER BY name LIMIT 10
+```
+
+### Types of SQL Joins with QueryBuilder
+
+The SQL Query Builder library supports various types of JOIN operations to combine rows from multiple tables based on a related column between them. Below are examples of different JOIN types you can use with `QueryBuilder`:
+
+#### 1. INNER JOIN
+
+An INNER JOIN returns records that have matching values in both tables.
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create a SELECT query with INNER JOIN
+$query = QueryBuilder::select('u.name', 'a.address')
+    ->from('users u')
+    ->innerJoin('addresses a ON u.id = a.user_id');
+
+echo $query; // Outputs: SELECT u.name, a.address FROM users u INNER JOIN addresses a ON u.id = a.user_id
+```
+
+#### 2. LEFT JOIN
+
+A LEFT JOIN returns all records from the left table (first table) and the matched records from the right table (second table). If there is no match, the result is NULL on the right side.
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create a SELECT query with LEFT JOIN
+$query = QueryBuilder::select('u.name', 'a.address')
+    ->from('users u')
+    ->leftJoin('addresses a ON u.id = a.user_id');
+
+echo $query; // Outputs: SELECT u.name, a.address FROM users u LEFT JOIN addresses a ON u.id = a.user_id
+```
+
+#### 3. RIGHT JOIN
+
+A RIGHT JOIN returns all records from the right table (second table) and the matched records from the left table (first table). If there is no match, the result is NULL on the left side.
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create a SELECT query with RIGHT JOIN
+$query = QueryBuilder::select('u.name', 'a.address')
+    ->from('users u')
+    ->rightJoin('addresses a ON u.id = a.user_id');
+
+echo $query; // Outputs: SELECT u.name, a.address FROM users u RIGHT JOIN addresses a ON u.id = a.user_id
+```
+
+### Creating a SELECT Query with DISTINCT
+
+You can use the `distinct()` method to specify a `SELECT DISTINCT` query with QueryBuilder.
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create a SELECT query with DISTINCT using QueryBuilder
+$query = QueryBuilder::select('name', 'email')
+    ->distinct()
+    ->from('users')
+    ->where('status = "active"')
+    ->orderBy('name')
+    ->limit(10);
+
+echo $query; // Outputs: SELECT DISTINCT name, email FROM users WHERE status = "active" ORDER BY name LIMIT 10
+```
+
+### Creating a SELECT Query with GROUP BY
+
+You can use the `groupBy()` method to specify a `GROUP BY` clause with QueryBuilder.
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create a SELECT query with GROUP BY using QueryBuilder
+$query = QueryBuilder::select('category_id', 'COUNT(*) as count')
+    ->from('products')
+    ->groupBy('category_id');
+
+echo $query; // Outputs: SELECT category_id, COUNT(*) as count FROM products GROUP BY category_id
+```
+
+### Creating a SELECT Query with HAVING Clause
+
+You can use the `having()` method to specify a `HAVING` clause with QueryBuilder.
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create a SELECT query with HAVING using QueryBuilder
+$query = QueryBuilder::select('category_id', 'COUNT(*) as count')
+    ->from('products')
+    ->groupBy('category_id')
+    ->having('COUNT(*) > 5');
+
+echo $query; // Outputs: SELECT category_id, COUNT(*) as count FROM products GROUP BY category_id HAVING COUNT(*) > 5
+```
+
+
+---
+
+### Creating an INSERT Query
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create an INSERT query
+$query = QueryBuilder::insert('users')
+    ->setValue('name', '"John Doe"')
+    ->setValue('email', '"john.doe@example.com"')
+    ->setValue('status', '"active"');
+
+echo $query; // Outputs: INSERT INTO users (name, email, status) VALUES ("John Doe", "john.doe@example.com", "active")
+```
+
+### Creating an UPDATE Query
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create an UPDATE query
+$query = QueryBuilder::update('users')
+    ->set('status', '"inactive"')
+    ->where('id = 123');
+
+echo $query; // Outputs: UPDATE users SET status = "inactive" WHERE id = 123
+```
+
+### Creating an DELETE Query
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+
+// Create a DELETE query
+$query = QueryBuilder::delete('users')
+    ->where('status = "inactive"');
+
+echo $query; // Outputs: DELETE FROM users WHERE status = "inactive"
+```
+
+### Creating a SELECT Query with Custom Expression
+
+```php
+use Michel\SqlMapper\QueryBuilder;
+use Michel\SqlMapper\Expression\Expr;
+
+// Example of a query with a custom expression
+$whereClause = Expr::greaterThan('age', '18');
+$query = QueryBuilder::select('name', 'email')
+    ->from('users')
+    ->where($whereClause);
+
+echo $query; // Outputs: SELECT name, email FROM users WHERE age > 18
+```
+
+### List of Available Expressions (`Expr`)
+
+Here is a comprehensive list of available static methods in the `Expr` class along with examples demonstrating their usage:
+
+#### `Expr::equal(string $key, string $value)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate an equal comparison expression
+$equalExpr = Expr::equal('age', '30');
+echo "Equal Expression: $equalExpr"; // Outputs: Equal Expression: age = 30
+```
+
+#### `Expr::notEqual(string $key, string $value)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate a not equal comparison expression
+$notEqualExpr = Expr::notEqual('status', '"active"');
+echo "Not Equal Expression: $notEqualExpr"; // Outputs: Not Equal Expression: status <> "active"
+```
+
+#### `Expr::greaterThan(string $key, string $value)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate a greater than comparison expression
+$greaterThanExpr = Expr::greaterThan('salary', '50000');
+echo "Greater Than Expression: $greaterThanExpr"; // Outputs: Greater Than Expression: salary > 50000
+```
+
+#### `Expr::greaterThanEqual(string $key, string $value)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate a greater than or equal comparison expression
+$greaterThanEqualExpr = Expr::greaterThanEqual('points', '100');
+echo "Greater Than or Equal Expression: $greaterThanEqualExpr"; // Outputs: Greater Than or Equal Expression: points >= 100
+```
+
+#### `Expr::lowerThan(string $key, string $value)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate a lower than comparison expression
+$lowerThanExpr = Expr::lowerThan('price', '50');
+echo "Lower Than Expression: $lowerThanExpr"; // Outputs: Lower Than Expression: price < 50
+```
+
+#### `Expr::lowerThanEqual(string $key, string $value)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate a lower than or equal comparison expression
+$lowerThanEqualExpr = Expr::lowerThanEqual('quantity', '10');
+echo "Lower Than or Equal Expression: $lowerThanEqualExpr"; // Outputs: Lower Than or Equal Expression: quantity <= 10
+```
+
+#### `Expr::isNull(string $key)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate an IS NULL expression
+$isNullExpr = Expr::isNull('description');
+echo "IS NULL Expression: $isNullExpr"; // Outputs: IS NULL Expression: description IS NULL
+```
+
+#### `Expr::isNotNull(string $key)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate an IS NOT NULL expression
+$isNotNullExpr = Expr::isNotNull('created_at');
+echo "IS NOT NULL Expression: $isNotNullExpr"; // Outputs: IS NOT NULL Expression: created_at IS NOT NULL
+```
+
+#### `Expr::in(string $key, array $values)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate an IN expression
+$inExpr = Expr::in('category_id', [1, 2, 3]);
+echo "IN Expression: $inExpr"; // Outputs: IN Expression: category_id IN (1, 2, 3)
+```
+
+#### `Expr::notIn(string $key, array $values)`
+
+```php
+use Michel\SqlMapper\Expression\Expr;
+
+// Example: Generate a NOT IN expression
+$notInExpr = Expr::notIn('role', ['"admin"', '"manager"']);
+echo "NOT IN Expression: $notInExpr"; // Outputs: NOT IN Expression: role NOT IN ("admin", "manager")
+```
+
+These examples demonstrate how to use each `Expr` class method to generate SQL expressions for various comparison and conditional operations. Incorporate these methods into your SQL Query Builder usage to construct complex and precise SQL queries effectively.
+
+---
+
+## Relational Data Mapping with `JoinQL`
+
+While the `QueryBuilder` is excellent for creating raw SQL queries, the `JoinQL` class provides a higher-level abstraction to automatically handle relational data. It is designed to transform flat result sets from complex queries with `JOINs` into structured, nested arrays (or object graphs), making it incredibly easy to work with one-to-one and one-to-many relationships.
+
+### How It Works
+
+`JoinQL` builds upon the `Select` query builder but adds a layer of "graph-aware" logic. When you define a join, you also specify the nature of the relationship (e.g., one-to-many) and the desired key for the nested data. After executing the query, `JoinQL` processes the results and intelligently groups child rows under their parent entities.
+
+### Example: Fetching a User and All Their Posts (One-to-Many)
+
+Imagine you have a `users` table and a `posts` table. Here’s how you can fetch a user and embed all their posts directly in the result, without manually looping through the results.
+
+```php
+use Michel\SqlMapper\QL\JoinQL;
+
+// 1. Initialize JoinQL with your PDO connection
+$pdo = new PDO('your_dsn', 'user', 'pass');
+$joinQL = new JoinQL($pdo);
+
+// 2. Build the query
+$user = $joinQL
+    // Start with the primary entity
+    ->select('users', 'u', ['id', 'name'])
+    
+    // Join the related entity
+    ->leftJoin(
+        'u',                  // From table alias (the parent)
+        'posts',              // To table (the child)
+        'p',                  // To table alias
+        ['u.id = p.user_id'], // Join condition
+        true,                 // IS a one-to-many relationship
+        'posts'               // The key for the nested posts array in the result
+    )
+    
+    // Add conditions and parameters as usual
+    ->where('u.id = :user_id')
+    ->setParam('user_id', 1)
+    
+    // 3. Fetch the structured result
+    ->getOneOrNullResult();
+
+/*
+The $user variable will contain a perfectly structured array:
+
+[
+    'id' => 1,
+    'name' => 'John Doe',
+    'posts' => [
+        [
+            'id' => 123,
+            'title' => 'My First Post',
+            'content' => '...'
+        ],
+        [
+            'id' => 124,
+            'title' => 'Another Post',
+            'content' => '...'
+        ]
+    ]
+]
+*/
+```
+
+### Example: Fetching a Post and Its Author (One-to-One)
+
+Here is how to handle a `one-to-one` relationship, where a post has a single author.
+
+```php
+use Michel\SqlMapper\\QL\JoinQL;
+
+$pdo = new PDO('your_dsn', 'user', 'pass');
+$joinQL = new JoinQL($pdo);
+
+$post = $joinQL
+    ->select('posts', 'p', ['id', 'title', 'content'])
+    ->leftJoin(
+        'p',                  // From table alias
+        'users',              // To table
+        'u',                  // To table alias
+        ['p.user_id = u.id'], // Join condition
+        false,                // NOT a one-to-many relationship (it's one-to-one)
+        'author'              // The key for the nested author object
+    )
+    ->where('p.id = :post_id')
+    ->setParam('post_id', 123)
+    ->getOneOrNullResult();
+
+/*
+The $post variable will look like this:
+
+[
+    'id' => 123,
+    'title' => 'My First Post',
+    'content' => '...',
+    'author' => [
+        'id' => 1,
+        'name' => 'John Doe'
+    ]
+]
+*/
+```
+
+By using `JoinQL`, you delegate the complex task of structuring relational data to the library, resulting in cleaner, more readable application code.
+
+## Features
+
+- Fluent generation of SELECT, INSERT, UPDATE, and DELETE queries.
+- Secure SQL query building to prevent SQL injection vulnerabilities.
+- Support for WHERE, ORDER BY, GROUP BY, HAVING, LIMIT, and JOIN clauses.
+- Simplified methods for creating custom SQL expressions.
+
+## License
+
+This library is open-source software licensed under the [MIT license](LICENSE).

+ 24 - 0
composer.json

@@ -0,0 +1,24 @@
+{
+  "name": "michel/php-sql-mapper",
+  "description": "A powerful PHP library that not only builds complex SQL queries but also maps the results into structured object graphs. It simplifies handling relational data by automatically transforming flat result sets from joins into nested arrays, making it ideal for working with one-to-one and one-to-many relationships.",
+  "type": "library",
+  "license": "MIT",
+  "authors": [
+    {
+      "name": "Michel.F"
+    }
+  ],
+  "autoload": {
+    "psr-4": {
+      "Michel\\SqlMapper\\": "src",
+      "Test\\Michel\\SqlMapper\\": "tests"
+    }
+  },
+  "require": {
+    "php": ">=7.4",
+    "ext-pdo": "*"
+  },
+  "require-dev": {
+    "michel/unitester": "^1.0.0"
+  }
+}

+ 37 - 0
src/Count.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Michel\Sql;
+
+use http\Encoding\Stream;
+
+final class Count
+{
+    private Select $select;
+    private function __construct(Select $select)
+    {
+        $this->select = $select->cloneAndResetParts('fields','order','limit','offset','distinct');
+    }
+
+    public static function createFromSelect(Select $select): self
+    {
+        return new self($select);
+    }
+
+    public function __toString(): string
+    {
+        return $this->select->__toString();
+    }
+
+    public function on(string $field, ?string $as = null, bool $distinct = false): self
+    {
+        $expr = $distinct ? sprintf('COUNT(DISTINCT %s)', $field)
+            : sprintf('COUNT(%s)', $field);
+
+        if ($as !== null) {
+            $expr .= ' AS ' . $as;
+        }
+
+        $this->select->select($expr);
+        return $this;
+    }
+}

+ 55 - 0
src/Delete.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace Michel\Sql;
+
+use Michel\SqlMapper\Interfaces\QueryInterface;
+
+/**
+ * @package	php-query-builder
+ * @author	PhpDevCommunity <dev@phpdevcommunity.com>
+ * @license	https://opensource.org/licenses/MIT	MIT License
+ * @link	https://www.phpdevcommunity.com
+ */
+final class Delete implements QueryInterface
+{
+    private string $table;
+    private array $conditions = [];
+
+    /**
+     *
+     * @param string $table The table name
+     * @param string|null $alias The table alias (optional)
+     */
+    public function __construct(string $table, ?string $alias = null)
+    {
+        $this->table = $alias === null ? $table : "${table} AS ${alias}";;
+    }
+
+    /**
+     * Get the string representation of the DELETE query.
+     *
+     * @return string
+     */
+    public function __toString(): string
+    {
+        if (empty($this->table)) {
+            throw new \LogicException('No table to delete from');
+        }
+
+        return 'DELETE FROM ' . $this->table . ($this->conditions === [] ? '' : ' WHERE ' . implode(' AND ', $this->conditions));
+    }
+
+    /**
+     * Add WHERE conditions to the DELETE query.
+     *
+     * @param string ...$where The conditions to add
+     * @return self
+     */
+    public function where(string ...$where): self
+    {
+        foreach ($where as $arg) {
+            $this->conditions[] = $arg;
+        }
+        return $this;
+    }
+}

+ 154 - 0
src/Expression/Expr.php

@@ -0,0 +1,154 @@
+<?php
+
+namespace Michel\SqlMapper\Expression;
+
+/**
+ * @package	php-query-builder
+ * @author	PhpDevCommunity <dev@phpdevcommunity.com>
+ * @license	https://opensource.org/licenses/MIT	MIT License
+ * @link	https://www.phpdevcommunity.com
+ */
+final class Expr
+{
+    /**
+     * Returns an SQL expression for equal comparison.
+     *
+     * @param string $key
+     * @param string $value
+     * @return string
+     */
+    public static function equal(string $key, string $value): string
+    {
+        return "$key = $value";
+    }
+
+    /**
+     * Returns an SQL expression for not equal comparison.
+     *
+     * @param string $key
+     * @param string $value
+     * @return string
+     */
+    public static function notEqual(string $key, string $value): string
+    {
+        return "$key <> $value";
+    }
+
+    /**
+     * Returns an SQL expression for greater than comparison.
+     *
+     * @param string $key
+     * @param string $value
+     * @return string
+     */
+    public static function greaterThan(string $key, string $value): string
+    {
+        return "$key > $value";
+    }
+
+    /**
+     * Returns an SQL expression for greater than or equal comparison.
+     *
+     * @param string $key
+     * @param string $value
+     * @return string
+     */
+    public static function greaterThanEqual(string $key, string $value): string
+    {
+        return "$key >= $value";
+    }
+
+    /**
+     * Returns an SQL expression for lower than comparison.
+     *
+     * @param string $key
+     * @param string $value
+     * @return string
+     */
+    public static function lowerThan(string $key, string $value): string
+    {
+        return "$key < $value";
+    }
+
+    /**
+     * Returns an SQL expression for lower than or equal comparison.
+     *
+     * @param string $key
+     * @param string $value
+     * @return string
+     */
+    public static function lowerThanEqual(string $key, string $value): string
+    {
+        return "$key <= $value";
+    }
+
+    /**
+     * Returns an SQL expression for checking if a column is NULL.
+     *
+     * @param string $key
+     * @return string
+     */
+    public static function isNull(string $key): string
+    {
+        return "$key IS NULL";
+    }
+
+    /**
+     * Returns an SQL expression for checking if a column is NOT NULL.
+     *
+     * @param string $key
+     * @return string
+     */
+    public static function isNotNull(string $key): string
+    {
+        return "$key IS NOT NULL";
+    }
+
+    /**
+     * Returns an SQL expression for checking if a value is in a list of values.
+     *
+     * @param string $key
+     * @param array $values
+     * @return string
+     */
+    public static function in(string $key, array $values): string
+    {
+        $values = array_map(function ($val) {
+            if (strpos($val, ':') === 0 || $val === '?') {
+                return $val;
+            }
+
+            if (is_string($val)) {
+                return "'$val'";
+            }
+
+            return $val;
+        }, $values);
+
+        return "$key IN " . '(' . implode(', ', $values) . ')';
+    }
+
+    /**
+     * Returns an SQL expression for checking if a value is not in a list of values.
+     *
+     * @param string $key
+     * @param array $values
+     * @return string
+     */
+    public static function notIn(string $key, array $values): string
+    {
+        $values = array_map(function ($val) {
+            if (strpos($val, ':') === 0 || $val === '?') {
+                return $val;
+            }
+
+            if (is_string($val)) {
+                return "'$val'";
+            }
+
+            return $val;
+        }, $values);
+
+        return "$key NOT IN (" . implode(', ', $values) . ')';
+    }
+}

+ 162 - 0
src/Graph/Builder/GraphBuilder.php

@@ -0,0 +1,162 @@
+<?php
+
+namespace Michel\SqlMapper\Graph\Builder;
+
+
+use LogicException;
+
+final class GraphBuilder
+{
+    /**
+     * @var array
+     */
+    private array $dataFlattened;
+
+    /**
+     * @var array<Table>
+     */
+    private array $tables;
+    private string $primaryKey;
+    private ?Table $primaryTable = null;
+
+    public function __construct(
+        array  &$dataFlattened,
+        array  $tables,
+        string $primaryKey = 'id'
+    )
+    {
+
+        $this->dataFlattened = &$dataFlattened;
+        foreach ($tables as $table) {
+            if (!$table instanceof Table) {
+                throw new LogicException('Table must be an instance of Table');
+            }
+            $this->tables[$table->getAlias()] = $table;
+            if ($table->getParent() === null) {
+                $this->primaryTable = $table;
+            }
+        }
+        $this->primaryKey = $primaryKey;
+        if ($this->primaryTable === null) {
+            throw new LogicException('Primary table not found, need at least one table with no parent');
+        }
+    }
+
+    public function buildGraph(): array
+    {
+        $graph = [];
+
+        if (count($this->dataFlattened) === 0) {
+            throw new LogicException('No data to build graph from, dataFlattened is empty');
+        }
+
+        foreach ($this->dataFlattened as $row) {
+            $this->insertRowIntoGraph($graph, $row);
+        }
+        $this->dataFlattened = [];
+
+        $this->cleanGraph($graph);
+        return reset($graph);
+    }
+
+    private function insertRowIntoGraph(array &$graph, array $row): void
+    {
+        $references = [];
+
+        foreach ($row as $key => $value) {
+            [$entity, $attribute] = explode('__', $key);
+            $parent = $this->tables[$entity]->getParent();
+            $table = $this->tables[$entity];
+            foreach ($table->getChildren() as $child) {
+                if ($child->getSourceForeignKey() === $attribute) {
+                    continue 2;
+                }
+            }
+
+            $foreignKey = $table->getSourceForeignKey();
+            if ($attribute === $foreignKey) {
+                continue;
+            }
+            if (!isset($references[$entity])) {
+                $parent = $parent !== null ? $parent->getAlias() : null;
+
+                if ($parent === null) {
+                    if (!isset($graph[$entity])) {
+                        $graph[$entity] = [];
+                    }
+                    $references[$entity] = &$graph[$entity];
+                } else {
+                    if (!isset($references[$parent])) {
+                        throw new LogicException("Parent is not defined: $parent");
+                    }
+
+                    $parentNode = &$references[$parent];
+                    if (!isset($parentNode[$entity])) {
+                        $parentNode[$entity] = [];
+                    }
+                    $references[$entity] = &$parentNode[$entity];
+                }
+            }
+
+            if ($attribute === $this->primaryKey) {
+                if (!isset($references[$entity][$value])) {
+                    $references[$entity][$value] = [$attribute => $value];
+                }
+                $references[$entity] = &$references[$entity][$value];
+            } else {
+                $references[$entity][$attribute] = $value;
+            }
+        }
+    }
+
+    private function cleanGraph(array &$graph): void
+    {
+        $updatedGraph = [];
+        $primaryTableAlias = $this->primaryTable->getAlias();
+        foreach ($graph as $key => &$value) {
+            if (!is_array($value)) {
+                $updatedGraph[$key] = $value;
+                continue;
+            }
+
+            if (array_key_exists($key, $this->tables)) {
+                $value = array_values($value);
+            }
+
+            $this->cleanGraph($value);
+            if ($this->allValuesAreNull($value)) {
+                $value = null;
+            }
+
+            if (!array_key_exists($key, $this->tables)) {
+                $updatedGraph[$key] = $value;
+                continue;
+            }
+            if ($primaryTableAlias === $key) {
+                $updatedGraph[$key] = $value;
+                continue;
+            }
+
+            $oneToMany = $this->tables[$key]->isOneToMany();
+            $keyRelation = $this->tables[$key]->getRelationKey();
+            $updatedGraph[$keyRelation] = $oneToMany === false ? ($value[0] ?? null) : $value ?? [];
+        }
+        $graph = $updatedGraph;
+        unset($updatedGraph);
+    }
+
+    private function allValuesAreNull(array $array): bool
+    {
+        foreach ($array as $value) {
+            if (!is_array($value) && $value !== null) {
+                return false;
+            }
+
+            if (is_array($value) && !empty($value)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+}

+ 83 - 0
src/Graph/Builder/Table.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace Michel\SqlMapper\Graph\Builder;
+
+final class Table
+{
+    private string $table;
+    private string $alias;
+    private ?Table $parent = null;
+
+    /**
+     * @var array<Table>
+     */
+    private array $children = [];
+
+    private bool $oneToMany = false;
+    private ?string $relationKey = null;
+    private ?string $sourceForeignKey = null;
+
+    public function __construct(
+        string $table,
+        string $alias,
+        bool $isOneToMany = false,
+        string $relationKey = null,
+        string $sourceForeignKey = null
+    )
+    {
+        $this->table = $table;
+        $this->alias = $alias;
+        $this->oneToMany = $isOneToMany;
+        $this->relationKey = $relationKey;
+        $this->sourceForeignKey = $sourceForeignKey;
+    }
+
+    public function getTable(): string
+    {
+        return $this->table;
+    }
+
+    public function getAlias(): string
+    {
+        return $this->alias;
+    }
+
+    public function isOneToMany(): bool
+    {
+        return $this->oneToMany;
+    }
+
+    public function getRelationKey(): ?string
+    {
+        return $this->relationKey ?? $this->getAlias();
+    }
+
+    public function getSourceForeignKey(): ?string
+    {
+        return $this->sourceForeignKey;
+    }
+
+
+    public function getParent(): ?Table
+    {
+        return $this->parent;
+    }
+
+    public function setParent(Table $parent): self
+    {
+        $this->parent = $parent;
+        return $this;
+    }
+
+    public function getChildren(): array
+    {
+        return $this->children;
+    }
+
+    public function addChild(Table $child): self
+    {
+        $child->setParent($this);
+        $this->children[] = $child;
+        return $this;
+    }
+}

+ 63 - 0
src/Insert.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace Michel\Sql;
+
+use Michel\SqlMapper\Interfaces\QueryInterface;
+
+/**
+ * @package	php-query-builder
+ * @author	PhpDevCommunity <dev@phpdevcommunity.com>
+ * @license	https://opensource.org/licenses/MIT	MIT License
+ * @link	https://www.phpdevcommunity.com
+ */
+final class Insert implements QueryInterface
+{
+    /**
+     * @var string The table name for the insert operation.
+     */
+    private string $table;
+
+    /**
+     * @var array The columns and values to be inserted.
+     */
+    private array $values = [];
+
+    /**
+     * Constructor for the Insert class.
+     *
+     * @param string $table The table name for the insert operation.
+     */
+    public function __construct(string $table)
+    {
+        $this->table = $table;
+    }
+
+    /**
+     * Generate the SQL string for the insert operation.
+     *
+     * @return string The SQL string for the insert operation.
+     * @throws \Exception
+     */
+    public function __toString(): string
+    {
+        if (empty($this->values)) {
+            throw new \LogicException('No values to insert');
+        }
+
+        return 'INSERT INTO ' . $this->table
+            . ' (' . implode(', ',array_keys($this->values)) . ') VALUES (' . implode(', ',$this->values) . ')';
+    }
+
+    /**
+     * Set a column and value for the insert operation.
+     *
+     * @param string $column The column name.
+     * @param string $value The value to be inserted.
+     * @return self
+     */
+    public function setValue(string $column, string $value): self
+    {
+        $this->values[$column] = $value;
+        return $this;
+    }
+}

+ 14 - 0
src/Interfaces/QueryInterface.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace Michel\SqlMapper\Interfaces;
+
+/**
+ * @package	php-query-builder
+ * @author	PhpDevCommunity <dev@phpdevcommunity.com>
+ * @license	https://opensource.org/licenses/MIT	MIT License
+ * @link	https://www.phpdevcommunity.com
+ */
+interface QueryInterface
+{
+    public function __toString(): string;
+}

+ 378 - 0
src/QL/JoinQL.php

@@ -0,0 +1,378 @@
+<?php
+
+namespace Michel\SqlMapper\QL;
+
+use LogicException;
+use PDO;
+use PDOStatement;
+use Michel\SqlMapper\Count;
+use Michel\SqlMapper\Graph\Builder\GraphBuilder;
+use Michel\SqlMapper\Graph\Builder\Table;
+use Michel\SqlMapper\Select;
+
+final class JoinQL
+{
+    private PDO $pdo;
+    private ?Select $selectQuery = null;
+    private ?string $firstTable = null;
+    private ?string $firstAlias = null;
+    private array $joins = [];
+    private string $primaryKey;
+    private ?int $aliasMaxLength;
+    private ?array $lastRow = null;
+    private int $countRows = 0;
+    private ?int $offset = null;
+    private ?int $limit = null;
+    private array $params = [];
+    private array $aliases = [];
+
+    public function __construct(PDO $pdo, string $primaryKey = 'id', ?int $aliasMaxLength = null)
+    {
+        $this->pdo = $pdo;
+        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+        $this->primaryKey = $primaryKey;
+        $this->aliasMaxLength = $aliasMaxLength;
+    }
+
+    public function select(string $table, string $aliasTable, array $columns): self
+    {
+        $this->resolveColumns($aliasTable, $columns);
+
+        $this->selectQuery = (new Select($columns))->from($table, $aliasTable);
+        $this->firstTable = $table;
+        $this->firstAlias = $aliasTable;
+        return $this;
+    }
+
+    public function addSelect(string $aliasTable, array $columns): self
+    {
+        $this->resolveColumns($aliasTable, $columns);
+
+        if ($this->selectQuery === null) {
+            throw new LogicException(
+                'You must call the select() method first to define the main table for the query ' .
+                'before adding a SELECT clause.'
+            );
+        }
+        $this->selectQuery->select(...$columns);
+        return $this;
+
+    }
+
+    public function where(string ...$where): self
+    {
+        if ($this->selectQuery === null) {
+            throw new LogicException(
+                'You must call the select() method first to define the main table for the query ' .
+                'before adding a WHERE clause.'
+            );
+        }
+        $this->selectQuery->where(...$where);
+        return $this;
+    }
+
+    public function orderBy($sort, $order = 'ASC'): self
+    {
+        if ($this->selectQuery === null) {
+            throw new LogicException(
+                'You must call the select() method first to define the main table for the query ' .
+                'before adding an ORDER BY clause.'
+            );
+        }
+        $this->selectQuery->orderBy($sort, $order);
+        return $this;
+    }
+
+    public function setFirstResult(?int $firstResult): self
+    {
+        if ($this->selectQuery === null) {
+            throw new LogicException(
+                'You must call the select() method first to define the main table for the query ' .
+                'before adding an FIRST RESULT clause.'
+            );
+        }
+        $this->offset = $firstResult;
+        return $this;
+    }
+
+    public function setMaxResults(?int $maxResults): self
+    {
+        if ($this->selectQuery === null) {
+            throw new LogicException(
+                'You must call the select() method first to define the main table for the query ' .
+                'before adding a MAX RESULTS clause.'
+            );
+        }
+        $this->limit = $maxResults;
+        return $this;
+    }
+
+    public function leftJoin(
+        string $fromTable,            // The source table (the table to join from or alias)
+        string $toTable,              // The target table (the table to join to)
+        string $toTableAlias,         // The alias for the joined table (used in the query)
+        array  $joinConditions,       // The conditions for the JOIN clause (e.g., ['posts.user_id', '=', 'users.id'])
+        bool   $isOneToMany = false,  // Whether the relationship is OneToMany (default is false for OneToOne)
+        string $relationKey = null,   // The key to use for the relationship in the result set (e.g., 'user' to replace 'user_id')
+        string $sourceForeignKey = null // The foreign key in the source table (e.g., 'user_id' in 'posts')
+    ): self
+    {
+        if ($this->selectQuery === null) {
+            throw new LogicException(
+                'You must call the select() method first to define the main table for the query ' .
+                'before adding a LEFT JOIN clause.'
+            );
+        }
+        $this->joins[$toTableAlias] = [
+            'type' => 'left',
+            'from' => $fromTable,
+            'to' => $toTable,
+            'alias' => $toTableAlias,
+            'key_relation' => $relationKey ?? $toTableAlias,
+            'condition' => $joinConditions,
+            'one_to_many' => $isOneToMany,
+            'foreign_key' => $sourceForeignKey
+        ];
+        $this->selectQuery->leftJoin(sprintf('%s %s %s', $toTable, $toTableAlias, sprintf('ON %s', implode(' AND ', $joinConditions))));
+
+        return $this;
+    }
+
+    public function innerJoin(
+        string $fromTable,            // The source table (the table to join from or alias)
+        string $toTable,              // The target table (the table to join to)
+        string $toTableAlias,         // The alias for the joined table (used in the query)
+        array  $joinConditions,       // The conditions for the JOIN clause (e.g., ['posts.user_id', '=', 'users.id'])
+        bool   $isOneToMany = false,  // Whether the relationship is OneToMany (default is false for OneToOne)
+        string $relationKey = null,   // The key to use for the relationship in the result set (e.g., 'user' to replace 'user_id')
+        string $sourceForeignKey = null // The foreign key in the source table (e.g., 'user_id' in 'posts')
+    ): self
+    {
+        if ($this->selectQuery === null) {
+            throw new LogicException(
+                'You must call the select() method first to define the main table for the query ' .
+                'before adding a INNER JOIN clause.'
+            );
+        }
+        $this->joins[$toTableAlias] = [
+            'type' => 'inner',
+            'from' => $fromTable,
+            'to' => $toTable,
+            'alias' => $toTableAlias,
+            'key_relation' => $relationKey ?? $toTableAlias,
+            'condition' => $joinConditions,
+            'one_to_many' => $isOneToMany,
+            'foreign_key' => $sourceForeignKey
+        ];
+        $this->selectQuery->innerJoin(sprintf('%s %s %s', $toTable, $toTableAlias, sprintf('ON %s', implode(' AND ', $joinConditions))));
+        return $this;
+    }
+
+    public function setParam(string $name, $value): self
+    {
+        $this->params[$name] = $value;
+        return $this;
+    }
+
+
+    public function getResultIterator(): iterable
+    {
+        $db = $this->executeQuery($this->selectQuery);
+        $this->lastRow = null;
+        $this->countRows = 0;
+        $count = 0;
+        while (($rows = $this->fetchIterator($db)) !== null) {
+            if ($rows instanceof \Traversable) {
+                $rows = iterator_to_array($rows);
+            }
+
+            if (empty($rows)) {
+                break;
+            }
+
+            foreach ($rows as $row) {
+                if ($this->limit !== null && $count >= $this->limit) {
+                    break 2;
+                }
+                yield $row;
+                $count++;
+            }
+        }
+
+        $db->closeCursor();
+    }
+
+    public function getOneOrNullResult(): ?array
+    {
+        foreach ($this->getResultIterator() as $row) {
+            if ($row instanceof \Traversable) {
+                $row = iterator_to_array($row)[0] ?? null;
+            }
+            return $row;
+        }
+
+        return null;
+    }
+
+    public function getResult(): array
+    {
+        if ($this->limit !== null) {
+            $data = [];
+            foreach ($this->getResultIterator() as $row) {
+                $data[] = $row;
+            }
+            return $data;
+        }
+        $db = $this->executeQuery($this->selectQuery);
+        return $this->buildGraph($db->fetchAll(PDO::FETCH_ASSOC));
+    }
+
+    public function count(): int
+    {
+        $field = sprintf('%s.%s', $this->firstAlias, $this->primaryKey);
+        $count = $this->toCountBuilder()->on($field, null, true);
+        $stmt = $this->executeQuery($count);
+        return $stmt->fetchColumn();
+    }
+
+    public function getQuery(): string
+    {
+        return $this->selectQuery->__toString();
+    }
+
+    public function toCountBuilder(): Count
+    {
+        if ($this->selectQuery === null) {
+            throw new LogicException(
+                'You must call the select() method first to define the main table for the query ' .
+                'before adding a SELECT clause.'
+            );
+        }
+        return Count::createFromSelect($this->selectQuery);
+    }
+
+    private function fetchIterator(PDOStatement $db): iterable
+    {
+        $data = null;
+        if ($this->lastRow !== null) {
+            $data[] = $this->lastRow;
+            $this->lastRow = null;
+        }
+
+        $previous = null;
+        $primaryKey = sprintf('%s__%s', $this->firstAlias, $this->primaryKey);
+        while ($row = $db->fetch(PDO::FETCH_ASSOC)) {
+            if ($previous === null) {
+                $previous = $row[$primaryKey];
+            }
+
+            if ($previous !== $row[$primaryKey]) {
+                $this->countRows++;
+                if ($this->offset !== null && $this->countRows <= $this->offset) {
+                    $data = [$row];
+                    $previous = $row[$primaryKey];
+                    continue;
+                }
+                $this->lastRow = $row;
+                break;
+            }
+            $data[] = $row;
+        }
+
+        if ($data === null) {
+            return null;
+        }
+
+        $items = $this->buildGraph($data) ?? [];
+        foreach ($items as $item) {
+            yield $item;
+        }
+        return null;
+    }
+
+    private function executeQuery(string $query): PDOStatement
+    {
+        $db = $this->pdo->prepare($query);
+        foreach ($this->params as $key => $value) {
+            $type = \PDO::PARAM_STR;
+            if (is_bool($value)) {
+                $type = \PDO::PARAM_BOOL;
+            }
+            if (is_string($key)) {
+                $db->bindValue(':' . $key, $value, $type);
+            } else {
+                $db->bindValue($key + 1, $value, $type);
+            }
+        }
+        $db->execute();
+        return $db;
+    }
+
+    private function buildGraph(array $data): array
+    {
+        $tables[$this->firstAlias] = new Table($this->firstTable, $this->firstAlias);
+        foreach ($this->joins as $alias => $join) {
+            $tables[$alias] = new Table($join['to'], $alias, $join['one_to_many'], $join['key_relation'], $join['foreign_key']);
+        }
+        foreach ($tables as $alias => $table) {
+            foreach ($this->joins as $join) {
+                if ($join['alias'] !== $alias) {
+                    continue;
+                }
+                /** @var array<Table> $parents */
+                $parents = array_values(array_filter($tables, function ($table) use ($join) {
+                    return $table->getTable() === $join['from'] || $join['from'] === $table->getAlias();
+                }));
+
+                foreach (array_unique($parents) as $parent) {
+                    $parent->addChild($table);
+                }
+            }
+        }
+
+        if ($data === []) {
+            return [];
+        }
+
+        foreach ($data as &$row) {
+            $copyRow = $row;
+            foreach ($row as $key => $value) {
+                $aliasTable = explode('__', $key)[0];
+                if (isset($this->aliases[$aliasTable][$key])) {
+                    $copyRow[$this->aliases[$aliasTable][$key]] = $value;
+                }
+            }
+
+            $row = $copyRow;
+        }
+
+        return (new GraphBuilder($data, $tables, $this->primaryKey))->buildGraph();
+    }
+
+    private function resolveColumns(string $aliasTable, array &$columns): void
+    {
+        $this->aliases[$aliasTable] = [];
+        $processedColumns = [];
+        foreach ($columns as $key => $columnName) {
+            $columnToSelect = $aliasTable . '.' . $columnName;
+            $alias = $columnToSelect;
+            $initialAlias = str_replace('.', '__', str_replace('`', '', $alias));
+            $finalAlias = $initialAlias;
+            if ($this->aliasMaxLength) {
+                $count = 0;
+                while (strlen($finalAlias) > $this->aliasMaxLength || array_key_exists($finalAlias, $this->aliases[$aliasTable])) {
+                    $count++;
+                    $maxLengthWithoutSuffix = $this->aliasMaxLength - $count;
+                    $truncatedBase = substr($initialAlias, 0, $maxLengthWithoutSuffix);
+                    $finalAlias = $truncatedBase . $count;
+                }
+            }
+            $this->aliases[$aliasTable][$finalAlias] = $initialAlias;
+            if (is_string($key)) {
+                $columnToSelect = $aliasTable . '.' . $key;
+            }
+            $processedColumns[] = sprintf('%s AS %s', $columnToSelect, $finalAlias);
+        }
+        $columns = $processedColumns;
+    }
+}

+ 59 - 0
src/QueryBuilder.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace Michel\Sql;
+
+/**
+ * @package	php-query-builder
+ * @author	PhpDevCommunity <dev@phpdevcommunity.com>
+ * @license	https://opensource.org/licenses/MIT	MIT License
+ * @link	https://www.phpdevcommunity.com
+ */
+/**
+ * Query builder class to create different types of SQL queries
+ */
+final class QueryBuilder
+{
+    /**
+     * Create a new SELECT query
+     *
+     * @param string ...$select The columns to select
+     * @return Select
+     */
+    public static function select(string ...$select): Select
+    {
+        return new Select($select);
+    }
+
+    /**
+     * Create a new INSERT query
+     *
+     * @param string $into The table name to insert into
+     * @return Insert
+     */
+    public static function insert(string $into): Insert
+    {
+        return new Insert($into);
+    }
+
+    /**
+     * Create a new UPDATE query
+     *
+     * @param string $table The table name to update
+     * @return Update
+     */
+    public static function update(string $table): Update
+    {
+        return new Update($table);
+    }
+
+    /**
+     * Create a new DELETE query
+     *
+     * @param string $table The table name to delete from
+     * @return Delete
+     */
+    public static function delete(string $table): Delete
+    {
+        return new Delete($table);
+    }
+}

+ 311 - 0
src/Select.php

@@ -0,0 +1,311 @@
+<?php
+
+namespace Michel\Sql;
+
+use LogicException;
+use Michel\SqlMapper\Interfaces\QueryInterface;
+
+/**
+ * @package    php-query-builder
+ * @author    PhpDevCommunity <dev@phpdevcommunity.com>
+ * @license    https://opensource.org/licenses/MIT	MIT License
+ * @link    https://www.phpdevcommunity.com
+ */
+
+/**
+ * Class Select
+ * Represents a SELECT query builder.
+ */
+final class Select implements QueryInterface
+{
+
+    /**
+     * @var array List of fields to select
+     */
+    private array $fields = [];
+
+    /**
+     * @var array List of conditions for WHERE clause
+     */
+    private array $conditions = [];
+
+    /**
+     * @var array List of order fields for ORDER BY clause
+     */
+    private array $order = [];
+
+    /**
+     * @var array List of tables for FROM clause
+     */
+    private array $from = [];
+
+    /**
+     * @var array List of fields to group by
+     */
+    private array $groupBy = [];
+
+    /**
+     * @var array List of conditions for HAVING clause
+     */
+    private array $having = [];
+
+    /**
+     * @var ?int Offset for the query
+     */
+    private ?int $offset = null;
+
+    /**
+     * @var ?int|string Limit for the query
+     */
+    private  $limit = null;
+
+    /**
+     * @var bool Indicates if DISTINCT should be used
+     */
+    private bool $distinct = false;
+
+    /**
+     * @var array List of JOIN clauses
+     */
+    private array $join = [];
+
+    /**
+     * Select constructor.
+     * @param array $select Initial fields to select
+     */
+    public function __construct(array $select)
+    {
+        $this->fields = $select;
+    }
+
+    /**
+     * Add fields to select
+     * @param string ...$select List of fields to select
+     * @return $this
+     */
+    public function select(string ...$select): self
+    {
+        foreach ($select as $arg) {
+            $this->fields[] = $arg;
+        }
+        return $this;
+    }
+
+    /**
+     * Build the string representation of the query
+     * @return string
+     */
+    public function __toString(): string
+    {
+        if ($this->from === []) {
+            throw new LogicException('No table specified');
+        }
+
+
+        $fields = $this->fields;
+        $distinct = $this->distinct;
+        if ($distinct && count($fields) === 1 && stripos($fields[0], 'COUNT(') === 0) {
+            if (stripos($fields[0], 'COUNT(DISTINCT') !== 0) {
+                $fields[0] = str_ireplace('COUNT(', 'COUNT(DISTINCT ', $fields[0]);
+            }
+            $distinct = false;
+        }
+
+        return trim(
+            'SELECT ' . ($distinct === true ? 'DISTINCT ' : '') . implode(', ', $fields)
+            . ' FROM ' . implode(', ', $this->from)
+            . ($this->join === [] ? '' : ' ' . implode(' ', $this->join))
+            . ($this->conditions === [] ? '' : ' WHERE ' . implode(' AND ', $this->conditions))
+            . ($this->groupBy === [] ? '' : ' GROUP BY ' . implode(', ', $this->groupBy))
+            . ($this->having === [] ? '' : ' HAVING ' . implode(' AND ', $this->having))
+            . ($this->order === [] ? '' : ' ORDER BY ' . implode(', ', $this->order))
+            . ($this->limit === null ? '' : ' LIMIT ' . $this->limit)
+            . ($this->offset === null ? '' : ' OFFSET ' . $this->offset)
+        );
+    }
+
+    public function cloneAndResetParts(...$parts): self
+    {
+        $clone = clone $this;
+
+        if (in_array('fields', $parts, true)) {
+            $clone->fields = [];
+        }
+        if (in_array('conditions', $parts, true)) {
+            $clone->conditions = [];
+        }
+        if (in_array('order', $parts, true)) {
+            $clone->order = [];
+        }
+        if (in_array('from', $parts, true)) {
+            $clone->from = [];
+        }
+        if (in_array('groupBy', $parts, true)) {
+            $clone->groupBy = [];
+        }
+        if (in_array('having', $parts, true)) {
+            $clone->having = [];
+        }
+        if (in_array('limit', $parts, true) || in_array('offset', $parts, true)) {
+            $clone->limit = null;
+            $clone->offset = null;
+        }
+
+        if (in_array('distinct', $parts, true)) {
+            $clone->distinct = false;
+        }
+
+        if (in_array('join', $parts, true)) {
+            $clone->join = [];
+        }
+
+        return $clone;
+    }
+
+    public function count(): Count
+    {
+        return Count::createFromSelect($this);
+    }
+
+
+    /**
+     * Add conditions for the WHERE clause
+     * @param string ...$where List of conditions
+     * @return $this
+     */
+    public function where(string ...$where): self
+    {
+        foreach ($where as $arg) {
+            $this->conditions[] = $arg;
+        }
+        return $this;
+    }
+
+    /**
+     * Set the table for the FROM clause
+     * @param string $table Table name
+     * @param string|null $alias Optional table alias
+     * @return $this
+     */
+    public function from(string $table, ?string $alias = null): self
+    {
+        $this->from[] = $alias === null ? $table : "${table} AS ${alias}";
+        return $this;
+    }
+
+    /**
+     * Set the limit for the query
+     * @param int|null $limit Limit value
+     * @return $this
+     */
+    public function limit(?int $limit, ?int $offset = null): self
+    {
+        if ($limit !== null && $limit <= 0) {
+            throw new LogicException('Limit must be greater than 0');
+        }
+
+        if ($offset !== null && $offset < 0) {
+            throw new LogicException('Offset must be greater than or equal to 0');
+        }
+
+        if ($offset !== null && $limit === null) {
+            $limit = PHP_INT_MAX;
+        }
+
+        $this->limit = $limit;
+        $this->offset = ($limit === null) ? null : $offset; // ← reset offset
+
+        return $this;
+    }
+
+    /**
+     * Add order fields for the ORDER BY clause
+     * @param string $sort Field to sort by
+     * @param string $order Sorting order (ASC or DESC)
+     * @return $this
+     */
+    public function orderBy(string $sort, string $order = 'ASC'): self
+    {
+        $this->order[] = "$sort $order";
+        return $this;
+    }
+
+    /**
+     * Add INNER JOIN clause
+     * @param string ...$join List of tables to join
+     * @return $this
+     */
+    public function innerJoin(string ...$join): self
+    {
+        foreach ($join as $arg) {
+            $this->join[] = "INNER JOIN $arg";
+        }
+        return $this;
+    }
+
+    /**
+     * Add LEFT JOIN clause
+     * @param string ...$join List of tables to join
+     * @return $this
+     */
+    public function leftJoin(string ...$join): self
+    {
+        foreach ($join as $arg) {
+            $this->join[] = "LEFT JOIN $arg";
+        }
+        return $this;
+    }
+
+    /**
+     * Perform a right join with the given tables.
+     *
+     * @param string ...$join The tables to perform right join with
+     * @return self
+     */
+    public function rightJoin(string ...$join): self
+    {
+        foreach ($join as $arg) {
+            $this->join[] = "RIGHT JOIN $arg";
+        }
+        return $this;
+    }
+
+    /**
+     * Set the query to return distinct results.
+     *
+     * @return self
+     */
+    public function distinct(): self
+    {
+        $this->distinct = true;
+        return $this;
+    }
+
+    /**
+     * Group the query results by the given columns.
+     *
+     * @param string ...$groupBy The columns to group by
+     * @return self
+     */
+    public function groupBy(string ...$groupBy): self
+    {
+        foreach ($groupBy as $arg) {
+            $this->groupBy[] = $arg;
+        }
+        return $this;
+    }
+
+    /**
+     * Set the HAVING clause for the query.
+     *
+     * @param string ...$having The conditions for the HAVING clause
+     * @return self
+     */
+    public function having(string ...$having): self
+    {
+        foreach ($having as $arg) {
+            $this->having[] = $arg;
+        }
+        return $this;
+    }
+}

+ 88 - 0
src/Update.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace Michel\Sql;
+
+use Michel\SqlMapper\Interfaces\QueryInterface;
+
+/**
+ * @package	php-query-builder
+ * @author	PhpDevCommunity <dev@phpdevcommunity.com>
+ * @license	https://opensource.org/licenses/MIT	MIT License
+ * @link	https://www.phpdevcommunity.com
+ */
+/**
+ * Class Update
+ *
+ * Represents an UPDATE query builder
+ */
+final class Update implements QueryInterface
+{
+    private string $table;
+    private array $conditions = [];
+    private array $columns = [];
+
+    /**
+     * Update constructor.
+     *
+     * @param string $table The table name
+     * @param string|null $alias The table alias (optional)
+     */
+    public function __construct(string $table, ?string $alias = null)
+    {
+        $this->table = $alias === null ? $table : "${table} AS ${alias}";
+    }
+
+    /**
+     * Convert the object to a string representation
+     *
+     * @return string
+     */
+    public function __toString(): string
+    {
+        if (empty($this->table)) {
+            throw new \LogicException('No table to update');
+        }
+
+        if (empty($this->columns)) {
+            throw new \LogicException('No columns to update');
+        }
+
+        $query = 'UPDATE ' . $this->table
+            . ' SET ' . implode(', ', $this->columns);
+
+        $query = trim($query);
+
+        if (!empty($this->conditions)) {
+            $query .= ' WHERE ' . implode(' AND ', $this->conditions);
+        }
+
+        return trim($query);
+    }
+
+    /**
+     * Add WHERE conditions to the query
+     *
+     * @param string ...$where The conditions to add
+     * @return self
+     */
+    public function where(string ...$where): self
+    {
+        foreach ($where as $arg) {
+            $this->conditions[] = $arg;
+        }
+        return $this;
+    }
+
+    /**
+     * Set the columns to update
+     *
+     * @param string $key The column name
+     * @param string $value The value to set
+     * @return self
+     */
+    public function set(string $key, string $value): self
+    {
+        $this->columns[] = $key . ' = ' . $value;
+        return $this;
+    }
+}

+ 40 - 0
tests/DeleteTest.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Test\Michel\Sql;
+
+use Michel\UniTester\TestCase;
+use Michel\SqlMapper\Delete;
+
+class DeleteTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testToStringWithConditions();
+        $this->testToStringWithoutConditions();
+    }
+
+    public function testToStringWithoutConditions()
+    {
+        $delete = new Delete('table_name');
+        $this->assertEquals('DELETE FROM table_name', $delete->__toString());
+    }
+
+    public function testToStringWithConditions()
+    {
+        $delete = new Delete('table_name');
+        $delete->where('condition1', 'condition2');
+        $this->assertEquals('DELETE FROM table_name WHERE condition1 AND condition2', $delete->__toString());
+    }
+
+}

+ 83 - 0
tests/ExprTest.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace Test\Michel\Sql;
+
+use Michel\SqlMapper\Expression\Expr;
+use Michel\UniTester\TestCase;
+
+class ExprTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testEqual();
+        $this->testNotEqual();
+        $this->testGreaterThan();
+        $this->testGreaterThanEqual();
+        $this->testLowerThan();
+        $this->testLowerThanEqual();
+        $this->testIsNull();
+        $this->testIsNotNull();
+        $this->testIn();
+        $this->testNotIn();
+    }
+    public function testEqual()
+    {
+        $this->assertEquals('id = 1', Expr::equal('id', '1'));
+    }
+
+    public function testNotEqual()
+    {
+        $this->assertEquals('name <> John', Expr::notEqual('name', 'John'));
+    }
+
+    public function testGreaterThan()
+    {
+        $this->assertEquals('quantity > 10', Expr::greaterThan('quantity', '10'));
+    }
+
+    public function testGreaterThanEqual()
+    {
+        $this->assertEquals('price >= 100', Expr::greaterThanEqual('price', '100'));
+    }
+
+    public function testLowerThan()
+    {
+        $this->assertEquals('age < 30', Expr::lowerThan('age', '30'));
+    }
+
+    public function testLowerThanEqual()
+    {
+        $this->assertEquals('score <= 80', Expr::lowerThanEqual('score', '80'));
+    }
+
+    public function testIsNull()
+    {
+        $this->assertEquals('description IS NULL', Expr::isNull('description'));
+    }
+
+    public function testIsNotNull()
+    {
+        $this->assertEquals('status IS NOT NULL', Expr::isNotNull('status'));
+    }
+
+    public function testIn()
+    {
+        $this->assertEquals('category IN (1, 2, 3)', Expr::in('category', [1, 2, 3]));
+    }
+
+    public function testNotIn()
+    {
+        $this->assertEquals("color NOT IN ('red', 'blue')", Expr::notIn('color', ['red', 'blue']));
+    }
+
+}

+ 41 - 0
tests/InsertTest.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Test\Michel\Sql;
+
+use Michel\SqlMapper\Insert;
+use Michel\UniTester\TestCase;
+
+class InsertTest extends TestCase
+{
+
+    protected function execute(): void
+    {
+        $this->testConstructor();
+        $this->testSetValue();
+    }
+    public function testConstructor()
+    {
+        $insert = new Insert('my_table');
+        $this->expectException(\LogicException::class , function () use ($insert) {
+            $insert->__toString();
+        });
+    }
+
+    public function testSetValue()
+    {
+        $insert = new Insert('my_table');
+        $insert->setValue('column1', 'value1')->setValue('column2', 'value2');
+        $this->assertEquals('INSERT INTO my_table (column1, column2) VALUES (value1, value2)', (string)$insert);
+    }
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+}

+ 328 - 0
tests/JoinQLTest.php

@@ -0,0 +1,328 @@
+<?php
+
+namespace Test\Michel\Sql;
+
+use PDO;
+use Michel\SqlMapper\QL\JoinQL;
+use Michel\UniTester\TestCase;
+
+class JoinQLTest extends TestCase
+{
+    private PDO $connection;
+
+    protected function setUp(): void
+    {
+        $this->connection = new PDO(
+            'sqlite::memory:',
+            null,
+            null,
+            [PDO::ATTR_EMULATE_PREPARES => false]
+        );
+        $this->setUpDatabaseSchema();
+    }
+
+
+    protected function tearDown(): void
+    {
+    }
+
+    protected function execute(): void
+    {
+        $this->testRealRelations();
+        $this->testSelect();
+        $this->testAddSelect();
+        $this->testWhere();
+        $this->testOrderBy();
+        $this->testLeftJoin();
+        $this->testInnerJoin();
+        $this->testWithLimit();
+        $this->testWithParams();
+        $this->testPaginationWithOffsetAndLimit();
+    }
+
+    public function testSelect(): void
+    {
+        $joinQl = new JoinQL($this->connection);
+        $joinQl->select('table', 'alias', ['column', 'column2' => 'alias__column']);
+        $this->assertEquals('SELECT alias.column AS alias__column, alias.column2 AS alias__alias__column FROM table AS alias', $joinQl->getQuery());
+    }
+
+    public function testAddSelect(): void
+    {
+        $joinQl = new JoinQL($this->connection);
+        $joinQl->select('table', 'alias', ['column']);
+        $joinQl->addSelect('alias', ['column']);
+        $this->assertEquals('SELECT alias.column AS alias__column, alias.column AS alias__column FROM table AS alias', $joinQl->getQuery());
+    }
+
+    public function testWhere(): void
+    {
+        $joinQl = new JoinQL($this->connection);
+        $joinQl->select('table', 'alias', ['column']);
+        $joinQl->where('alias.column = value');
+        $this->assertEquals('SELECT alias.column AS alias__column FROM table AS alias WHERE alias.column = value', $joinQl->getQuery());
+    }
+
+    public function testOrderBy(): void
+    {
+        $joinQl = new JoinQL($this->connection);
+        $joinQl->select('table', 'alias', ['column']);
+        $joinQl->orderBy('column');
+        $this->assertEquals('SELECT alias.column AS alias__column FROM table AS alias ORDER BY column ASC', $joinQl->getQuery());
+
+    }
+
+    public function testLeftJoin(): void
+    {
+        $joinQl = new JoinQL($this->connection);
+        $joinQl->select('table', 'alias', ['column']);
+        $joinQl->leftJoin('table', 'table2', 'alias2', ['column = column'], false, 'relation');
+        $this->assertEquals('SELECT alias.column AS alias__column FROM table AS alias LEFT JOIN table2 alias2 ON column = column', $joinQl->getQuery());
+    }
+
+    public function testInnerJoin(): void
+    {
+        $joinQl = new JoinQL($this->connection);
+        $joinQl->select('table', 'alias', ['column']);
+        $joinQl->innerJoin('table', 'table2', 'alias2', ['column = column'], false, 'relation');
+        $this->assertEquals('SELECT alias.column AS alias__column FROM table AS alias INNER JOIN table2 alias2 ON column = column', $joinQl->getQuery());
+    }
+
+    private function testRealRelations(): void
+    {
+        $joinQl = new JoinQL($this->connection, 'id', 5);
+        $joinQl
+            ->select('user', 'u', ['id', 'firstname' => 'firstname', 'lastname', 'email' => 'email_address', 'password', 'is_active', 'created_at'])
+            ->addSelect('p', ['id', 'title', 'user_id', 'content', 'created_at'])
+            ->addSelect('t', ['id', 'name' => 'tag_name', 'post_id'])
+            ->addSelect('c', ['id', 'body', 'post_id'])
+            ->leftJoin('user', 'post', 'p', ['u.id = p.user_id'], true, 'posts', 'user_id')
+            ->leftJoin('post', 'tag', 't', ['p.id = t.post_id'], true, 'tags', 'post_id')
+            ->leftJoin('post', 'comment', 'c', ['p.id = c.post_id'], true, 'comments', 'post_id');
+
+        foreach ($joinQl->getResult() as $row) {
+            $this->testRowOneToMany($row);
+        }
+        $row = $joinQl->getOneOrNullResult();
+        $this->testRowOneToMany($row);
+
+        foreach ($joinQl->getResultIterator() as $row) {
+            $this->testRowOneToMany($row);
+        }
+        $row = $joinQl->getOneOrNullResult();
+        $this->testRowOneToMany($row);
+
+        $joinQl = new JoinQL($this->connection);
+        $joinQl
+            ->select('post', 'p', ['id', 'title', 'user_id', 'content', 'created_at'])
+            ->addSelect('u', ['id', 'firstname' , 'lastname', 'email' , 'password', 'is_active', 'created_at'])
+            ->addSelect('t', ['id', 'name', 'post_id'])
+            ->addSelect('c', ['id', 'body', 'post_id'])
+            ->leftJoin('post', 'user', 'u', ['u.id = p.user_id'], false, 'user', 'user_id')
+            ->leftJoin('post', 'tag', 't', ['p.id = t.post_id'], true, 'tags', 'post_id')
+            ->leftJoin('post', 'comment', 'c', ['p.id = c.post_id'], true, 'comments', 'post_id')
+            ->orderBy('p.id', 'desc')
+            ->setMaxResults(3);
+        $data = $joinQl->getResult();
+        $this->assertEquals( 3 , count($data));
+        foreach ($data as $row) {
+            $this->assertTrue(array_key_exists('user', $row));
+            $this->assertTrue(array_key_exists('comments', $row));
+            $this->assertTrue(array_key_exists('tags', $row));
+        }
+
+        $count = $joinQl->count();
+        $this->assertEquals( 10 , $count);
+    }
+
+    private function testWithLimit()
+    {
+        $joinQl = new JoinQL($this->connection);
+        $joinQl
+            ->select('post', 'p', ['id', 'title', 'user_id', 'content', 'created_at'])
+            ->setMaxResults(1000000);
+        $data = $joinQl->getResult();
+        $this->assertEquals( 10 , count($data));
+
+        $count = $joinQl->count();
+        $this->assertEquals( 10 , $count);
+    }
+
+
+    public function testWithParams(): void {
+        $joinQl = new JoinQL($this->connection);
+        $joinQl
+            ->select('post', 'p', ['id', 'title', 'user_id', 'content', 'created_at'])
+            ->where('id > :id')
+            ->setParam('id', 5)
+            ->setMaxResults(1000000);
+        $data = $joinQl->getResult();
+
+
+        $this->assertEquals( 5 , count($data));
+        $this->assertEquals( 5 , $joinQl->count());
+    }
+
+
+    public function testPaginationWithOffsetAndLimit(): void
+    {
+        $joinQl = new JoinQL($this->connection);
+
+        // Page 1 : OFFSET 0 LIMIT 2
+        $page1 = $joinQl
+            ->select('post', 'p', ['id', 'title'])
+            ->setFirstResult(0)
+            ->setMaxResults(2)
+            ->getResult();
+
+        $this->assertEquals(2, count($page1));
+        $this->assertEquals(1, $page1[0]['id']);
+        $this->assertEquals(2, $page1[1]['id']);
+
+        // Page 2 : OFFSET 2 LIMIT 2
+        $joinQl = new JoinQL($this->connection);
+        $page2 = $joinQl
+            ->select('post', 'p', ['id', 'title'])
+            ->setFirstResult(2)
+            ->setMaxResults(2)
+            ->getResult();
+
+        $this->assertEquals(2, count($page2));
+        $this->assertEquals(3, $page2[0]['id']);
+        $this->assertEquals(4, $page2[1]['id']);
+
+        // Vérifier que le count() ignore la pagination
+        $total = $joinQl->count();
+        $this->assertEquals(10, $total);
+    }
+
+    private function testRowOneToMany($row)
+    {
+
+        $this->assertTrue(is_array($row));
+        $this->assertTrue(array_key_exists('id', $row));
+        $this->assertTrue(array_key_exists('firstname', $row));
+        $this->assertTrue(array_key_exists('lastname', $row));
+        $this->assertTrue(array_key_exists('email_address', $row));
+        $this->assertTrue(array_key_exists('password', $row));
+        $this->assertTrue(array_key_exists('is_active', $row));
+        $this->assertTrue(array_key_exists('posts', $row));
+        $this->assertTrue(array_key_exists('tags', $row['posts'][0]));
+        $this->assertTrue(array_key_exists('comments', $row['posts'][0]));
+        $this->assertTrue(array_key_exists('tag_name', $row['posts'][0]['tags'][0]));
+        $this->assertEquals(2, count($row['posts']));
+    }
+
+    protected function setUpDatabaseSchema(): void
+    {
+        $this->connection->exec('CREATE TABLE user (
+                id INTEGER PRIMARY KEY,
+                firstname VARCHAR(255),
+                lastname VARCHAR(255),
+                email VARCHAR(255),
+                password VARCHAR(255),
+                is_active BOOLEAN,
+                created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+            );');
+
+        $this->connection->exec('CREATE TABLE post (
+                id INTEGER PRIMARY KEY,
+                user_id INTEGER,
+                title VARCHAR(255),
+                content VARCHAR(255),
+                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                FOREIGN KEY (user_id) REFERENCES user (id)
+            );');
+
+        $this->connection->exec('CREATE TABLE tag (
+                id INTEGER PRIMARY KEY,
+                post_id INTEGER,
+                name VARCHAR(255)
+        )');
+        $this->connection->exec('CREATE TABLE comment (
+                id INTEGER PRIMARY KEY,
+                post_id INTEGER,
+                body VARCHAR(255)
+        )');
+
+
+        for ($i = 0; $i < 5; $i++) {
+            $user = [
+                'firstname' => 'John' . $i,
+                'lastname' => 'Doe' . $i,
+                'email' => $i . 'bqQpB@example.com',
+                'password' => 'password123',
+                'is_active' => true,
+            ];
+
+            $this->connection->exec("INSERT INTO user (firstname, lastname, email, password, is_active) VALUES (
+                '{$user['firstname']}',
+                '{$user['lastname']}',
+                '{$user['email']}',
+                '{$user['password']}',
+                '{$user['is_active']}'
+            )");
+        }
+
+        for ($i = 0; $i < 5; $i++) {
+            $id = uniqid('post_', true);
+            $post = [
+                'user_id' => $i + 1,
+                'title' => 'Post ' . $id,
+                'content' => 'Content ' . $id,
+            ];
+            $this->connection->exec("INSERT INTO post (user_id, title, content) VALUES (
+                '{$post['user_id']}',
+                '{$post['title']}',
+                '{$post['content']}'
+            )");
+
+            $id = uniqid('post_', true);
+            $post = [
+                'user_id' => $i + 1,
+                'title' => 'Post ' . $id,
+                'content' => 'Content ' . $id,
+            ];
+            $this->connection->exec("INSERT INTO post (user_id, title, content) VALUES (
+                '{$post['user_id']}',
+                '{$post['title']}',
+                '{$post['content']}'
+            )");
+        }
+
+        for ($i = 0; $i < 10; $i++) {
+            $id = uniqid('tag_', true);
+            $tag = [
+                'post_id' => $i + 1,
+                'name' => 'Tag ' . $id,
+            ];
+            $this->connection->exec("INSERT INTO tag (post_id, name) VALUES (
+                '{$tag['post_id']}',
+                '{$tag['name']}'
+            )");
+
+            $id = uniqid('tag_', true);
+            $tag = [
+                'post_id' => $i + 1,
+                'name' => 'Tag ' . $id,
+            ];
+            $this->connection->exec("INSERT INTO tag (post_id, name) VALUES (
+                '{$tag['post_id']}',
+                '{$tag['name']}'
+            )");
+        }
+
+        for ($i = 0; $i < 10; $i++) {
+            $id = uniqid('comment_', true);
+            $comment = [
+                'post_id' => $i + 1,
+                'body' => 'Comment ' . $id,
+            ];
+            $this->connection->exec("INSERT INTO comment (post_id, body) VALUES (
+                '{$comment['post_id']}',
+                '{$comment['body']}'
+            )");
+        }
+
+    }
+}

+ 164 - 0
tests/SelectTest.php

@@ -0,0 +1,164 @@
+<?php
+
+namespace Test\Michel\Sql;
+
+use Michel\SqlMapper\QueryBuilder;
+use Michel\SqlMapper\Select;
+use Michel\UniTester\TestCase;
+
+class SelectTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testToStringOnlyReturnsSqlString();
+        $this->testComplexQuery();
+        $this->testFrom();
+        $this->testWhere();
+        $this->testGroupBy();
+        $this->testHaving();
+        $this->testDistinct();
+        $this->testCount();
+    }
+    public function testToStringOnlyReturnsSqlString()
+    {
+        $select = new Select(['field1']);
+        $select->from('table1', 't1');
+        $select->where('condition1', 'condition2');
+        $select->groupBy('groupField');
+
+        $expectedSql = 'SELECT field1 FROM table1 AS t1 WHERE condition1 AND condition2 GROUP BY groupField';
+
+        $this->assertEquals($expectedSql, (string) $select);
+    }
+
+    public function testComplexQuery()
+    {
+        $select = new Select(['field1']);
+        $select->from('table1', 't1');
+        $select->leftJoin('table2 t2 ON t1.id = t2.t1_id');
+        $select->where('condition1', 'condition2');
+        $select->orderBy('field2', 'DESC');
+        $select->limit(10);
+
+        $expectedSql = 'SELECT field1 FROM table1 AS t1 LEFT JOIN table2 t2 ON t1.id = t2.t1_id WHERE condition1 AND condition2 ORDER BY field2 DESC LIMIT 10';
+
+        $this->assertEquals($expectedSql, (string) $select);
+
+        $count = $select->count()
+            ->on('t1.id', 'total', true)
+            ->on('t2.id', 'total2')
+        ;
+        $this->assertEquals("SELECT COUNT(DISTINCT t1.id) AS total, COUNT(t2.id) AS total2 FROM table1 AS t1 LEFT JOIN table2 t2 ON t1.id = t2.t1_id WHERE condition1 AND condition2", (string) $count);
+    }
+
+    public function testFrom()
+    {
+        $select = new Select(['field1']);
+        $select->from('table1', 't1');
+        $this->assertEquals('SELECT field1 FROM table1 AS t1', (string)$select);
+    }
+    public function testWhere()
+    {
+        $select = new Select(['field1']);
+        $select->where('condition1', 'condition2');
+
+        $this->expectException(\LogicException::class , function () use ($select) {
+            $select->__toString();
+        });
+    }
+
+    public function testHaving()
+    {
+        $query = QueryBuilder::select('category_id', 'COUNT(*) as count')
+            ->from('products')
+            ->groupBy('category_id')
+            ->having('COUNT(*) > 5');
+
+        $this->assertEquals('SELECT category_id, COUNT(*) as count FROM products GROUP BY category_id HAVING COUNT(*) > 5', (string) $query);
+    }
+
+    public function testGroupBy()
+    {
+        $query = QueryBuilder::select('category_id', 'COUNT(*) as count')
+            ->from('products')
+            ->groupBy('category_id');
+
+        $this->assertEquals('SELECT category_id, COUNT(*) as count FROM products GROUP BY category_id', (string) $query);
+    }
+    public function testDistinct()
+    {
+        $query = QueryBuilder::select('name', 'email')
+            ->distinct()
+            ->from('users')
+            ->where('status = "active"')
+            ->orderBy('name')
+            ->limit(10);
+
+        $this->assertEquals('SELECT DISTINCT name, email FROM users WHERE status = "active" ORDER BY name ASC LIMIT 10', (string) $query);
+    }
+
+    private function testCount()
+    {
+
+        $query = QueryBuilder::select('COUNT(p.id)')
+            ->from('products', 'p')
+            ->where('status = "active"')
+            ->leftJoin('users u ON p.user_id = u.id');
+
+        $this->assertEquals(
+            'SELECT COUNT(p.id) FROM products AS p LEFT JOIN users u ON p.user_id = u.id WHERE status = "active"',
+            (string) $query
+        );
+
+        $query = QueryBuilder::select('COUNT(p.id)')
+            ->distinct()
+            ->from('products', 'p')
+            ->where('status = "active"')
+            ->leftJoin('users u ON p.user_id = u.id');
+
+        $this->assertEquals(
+            'SELECT COUNT(DISTINCT p.id) FROM products AS p LEFT JOIN users u ON p.user_id = u.id WHERE status = "active"',
+            (string) $query
+        );
+
+        $query = QueryBuilder::select('COUNT(p.id) AS total')
+            ->distinct()
+            ->from('products', 'p')
+            ->where('status = "active"');
+
+        $this->assertEquals(
+            'SELECT COUNT(DISTINCT p.id) AS total FROM products AS p WHERE status = "active"',
+            (string) $query
+        );
+
+        $query = QueryBuilder::select('COUNT(DISTINCT p.id)')
+            ->from('products', 'p')
+            ->where('status = "active"');
+
+        $this->assertEquals(
+            'SELECT COUNT(DISTINCT p.id) FROM products AS p WHERE status = "active"',
+            (string) $query
+        );
+
+        $query = QueryBuilder::select('COUNT(DISTINCT p.id)')
+            ->distinct()
+            ->from('products', 'p')
+            ->where('status = "active"');
+
+        $this->assertEquals(
+            'SELECT COUNT(DISTINCT p.id) FROM products AS p WHERE status = "active"',
+            (string) $query
+        );
+    }
+}

+ 57 - 0
tests/UpdateTest.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace Test\Michel\Sql;
+
+use Michel\SqlMapper\Update;
+use Michel\UniTester\TestCase;
+
+class UpdateTest extends TestCase
+{
+    protected function execute(): void
+    {
+        $this->testNoTable();
+        $this->testToString();
+        $this->testWhereWithoutColumns();
+        $this->testSet();
+    }
+    public function testNoTable()
+    {
+        $update = new Update('my_table', 't');
+        $this->expectException(\LogicException::class, function () use ($update) {
+            $update->__toString();
+        });
+    }
+
+    public function testToString()
+    {
+        $update = new Update('my_table');
+        $update->set('column1', 'value1')->set('column2', 'value2')->where('condition1');
+        $this->assertEquals('UPDATE my_table SET column1 = value1, column2 = value2 WHERE condition1', (string)$update);
+    }
+
+    public function testWhereWithoutColumns()
+    {
+        $update = new Update('my_table');
+        $update->where('condition1', 'condition2');
+        $this->expectException(\LogicException::class, function () use ($update) {
+            $update->__toString();
+        });
+    }
+
+    public function testSet()
+    {
+        $update = new Update('my_table');
+        $update->set('column1', 'value1')->set('column2', 'value2');
+        $this->assertEquals('UPDATE my_table SET column1 = value1, column2 = value2', (string)$update);
+    }
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+}