diff --git a/src/Db.php b/src/Db.php index a21baa9..f765f5a 100644 --- a/src/Db.php +++ b/src/Db.php @@ -73,14 +73,6 @@ public function prepare(string $queryString, mixed $object = '\stdClass', array| return $statement; } - /** @param array|null $params */ - public function query(string $rawSql, array|null $params = null): static - { - $this->currentSql->setQuery($rawSql, $params); - - return $this; - } - protected function executeStatement(mixed $object = '\stdClass', mixed $extra = null): PDOStatement { $statement = $this->prepare((string) $this->currentSql, $object, $extra); diff --git a/src/Mapper.php b/src/Mapper.php index e2b2967..139699e 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -24,17 +24,17 @@ use function array_keys; use function array_merge; use function array_pop; +use function array_push; use function array_reverse; +use function array_values; use function class_exists; use function count; use function get_object_vars; use function is_array; -use function is_numeric; use function is_object; use function is_scalar; use function iterator_to_array; use function preg_match; -use function preg_replace; use function str_replace; /** Maps objects to database operations */ @@ -211,12 +211,12 @@ protected function extractAndOperateMixins(Collection $collection, array $cols): /** * @param array $columns * - * @return array + * @return array> */ protected function guessCondition(array &$columns, Collection $collection): array { - $primaryName = $this->getStyle()->identifier($collection->getName()); - $condition = [$primaryName => $columns[$primaryName]]; + $primaryName = $this->getStyle()->identifier($collection->getName()); + $condition = [[$primaryName, '=', $columns[$primaryName]]]; unset($columns[$primaryName]); return $condition; @@ -261,8 +261,8 @@ protected function rawInsert( $columns = $this->extractAndOperateMixins($collection, $columns); $name = $collection->getName(); $isInserted = $this->db - ->insertInto($name, $columns) - ->values($columns) + ->insertInto($name, array_keys($columns)) + ->values(array_values($columns)) ->exec(); if ($entity !== null) { @@ -372,7 +372,7 @@ protected function buildSelectStatement(Sql $sql, array $collections): Sql } } - return $sql->select($selectTable); + return $sql->select(...$selectTable); } /** @param array $collections */ @@ -390,7 +390,7 @@ protected function buildTables(Sql $sql, array $collections): Sql ); } - return $sql->where($conditions); + return empty($conditions) ? $sql : $sql->where($conditions); } /** @@ -403,22 +403,17 @@ protected function parseConditions(array &$conditions, Collection $collection, s $entity = $collection->getName(); $originalConditions = $collection->getCondition(); $parsedConditions = []; - $aliasedPk = $this->getStyle()->identifier($entity); - $aliasedPk = $alias . '.' . $aliasedPk; + $aliasedPk = $alias . '.' . $this->getStyle()->identifier($entity); if (is_scalar($originalConditions)) { - $parsedConditions = [$aliasedPk => $originalConditions]; + $parsedConditions[] = [$aliasedPk, '=', $originalConditions]; } elseif (is_array($originalConditions)) { foreach ($originalConditions as $column => $value) { - if (is_numeric($column)) { - $parsedConditions[$column] = preg_replace( - '/' . $entity . '[.](\w+)/', - $alias . '.$1', - $value, - ); - } else { - $parsedConditions[$alias . '.' . $column] = $value; + if (!empty($parsedConditions)) { + $parsedConditions[] = 'AND'; } + + $parsedConditions[] = [$alias . '.' . $column, '=', $value]; } } @@ -455,11 +450,18 @@ protected function parseCollection( $parentAlias = $parent ? $aliases[$parent] : null; $aliases[$entity] = $alias; - $conditions = $this->parseConditions( + $parsed = $this->parseConditions( $conditions, $collection, $alias, - ) ?: $conditions; + ); + if (!empty($parsed)) { + if (!empty($conditions)) { + $conditions[] = 'AND'; + } + + array_push($conditions, ...$parsed); + } //No parent collection means it's the first table in the query if ($parentAlias === null) { @@ -718,7 +720,7 @@ private function createStatement( $query = $this->generateQuery($collection); if ($withExtra instanceof Sql) { - $query->appendQuery($withExtra); + $query->concat($withExtra); } $statement = $this->db->prepare((string) $query, PDO::FETCH_NUM); diff --git a/src/Sql.php b/src/Sql.php index fb05737..ff985fb 100644 --- a/src/Sql.php +++ b/src/Sql.php @@ -4,272 +4,376 @@ namespace Respect\Relational; +use function array_fill; +use function array_filter; +use function array_is_list; +use function array_keys; +use function array_map; use function array_merge; -use function array_shift; -use function array_walk_recursive; +use function count; +use function current; use function implode; use function in_array; -use function is_int; -use function is_numeric; -use function preg_match; +use function is_array; +use function is_scalar; +use function is_string; +use function key; use function preg_replace; -use function rtrim; -use function sprintf; -use function stripos; use function strtoupper; -use function substr; -use function trim; +/** Fluent SQL builder with shape-based argument detection */ class Sql { - public const string SQL_OPERATORS = '/\s?(NOT)?\s?(=|==|<>|!=|>|>=|<|<=|LIKE)\s?$/'; - public const string PLACEHOLDER = '?'; + /** Instructions where assoc array values are raw identifiers, not parameterized */ + private const array RAW = ['on', 'select']; - protected string $query = ''; + /** + * Operators that expand an array value into multiple placeholders. + * Each entry: [prefix, separator, suffix, minValues, maxValues|null] + */ + private const array EXPAND = [ + 'IN' => ['(', ', ', ')', 1, null], + 'NOT IN' => ['(', ', ', ')', 1, null], + 'BETWEEN' => ['', ' AND ', '', 2, 2], + ]; + + /** @phpstan-var list */ + private(set) array $query = []; + + /** @phpstan-var list */ + private(set) array $params = []; - /** @var array */ - protected array $params = []; + private bool $raw = false; - /** @param array|null $params */ - public function __construct(string $rawSql = '', array|null $params = null) + public function __construct() { - $this->setQuery($rawSql, $params); } - public static function enclose(mixed $sql): mixed + public static function raw(string $expression): static { - if ($sql instanceof self) { - $sql->query = '(' . trim($sql->query) . ') '; - } elseif ($sql != '') { - $sql = '(' . trim($sql) . ') '; - } + $sql = new static(); + $sql->query[] = $expression; + $sql->raw = true; return $sql; } - /** @return array */ + public function concat(self $sql): static + { + $this->query[] = (string) $sql; + $this->params = array_merge($this->params, $sql->params); + + return $this; + } + + /** @return list */ public function getParams(): array { return $this->params; } - /** @param array|null $params */ - public function setQuery(string $rawSql, array|null $params = null): static + /** + * select('a', 'b'), from('t1', 't2'), orderBy('col') + * + * @param scalar|array>|self ...$items + */ + private function commaList(string|int|float|bool|array|self ...$items): static { - $this->query = $rawSql; - if ($params !== null) { - $this->params = $params; - } + $this->query[] = $this->formatList(...$items); return $this; } - /** @param array|null $params */ - public function appendQuery(mixed $sql, array|null $params = null): static + /** + * insertInto('table', ['col1', 'col2']), createTable('t', [['id', 'INT']]) + * + * @param list> $columns + */ + private function namedList(string $name, array $columns): static { - $this->query = trim($this->query) . ' ' . $sql; - if ($sql instanceof self) { - $this->params = array_merge($this->params, $sql->getParams()); - } - - if ($params !== null) { - $this->params = array_merge($this->params, $params); - } + $this->query[] = $name; + $this->query[] = '(' . $this->formatList(...$columns) . ')'; return $this; } - /** @param array $parts */ - protected function preBuild(string $operation, array $parts): static + /** + * on(['table.col' => 'other.col']) — raw identifier pairs, no params + * + * @param array> $pairs + */ + private function rawPairs(array $pairs): static { - $raw = ($operation == 'select' || $operation == 'on'); - $parts = $this->normalizeParts($parts, $raw); - if (empty($parts) && !in_array($operation, ['asc', 'desc', '_'], true)) { - return $this; - } + $parts = []; + foreach ($pairs as $k => $v) { + if (!is_scalar($v)) { + continue; + } - if ($operation == 'cond') { - // condition list - return $this->build('and', $parts); + $parts[] = $k . ' = ' . $v; } - $this->buildOperation($operation); - $operation = trim($operation, '_'); - - return $this->build($operation, $parts); - } + $this->query[] = implode(' AND ', $parts); - /** @param array $parts */ - protected function build(string $operation, array $parts): static - { - return match ($operation) { - 'select' => $this->buildAliases($parts), - 'and', 'having', 'where', 'between' => $this->buildKeyValues($parts, '%s ', ' AND '), - 'or' => $this->buildKeyValues($parts, '%s ', ' OR '), - 'set' => $this->buildKeyValues($parts), - 'on' => $this->buildComparators($parts, '%s ', ' AND '), - 'in', 'values' => $this->buildValuesList($parts), - 'alterTable' => $this->buildAlterTable($parts), - 'createTable', 'insertInto', 'replaceInto' => $this->buildCreate($parts), - default => $this->buildParts($parts), - }; + return $this; } - /** @param array $parts */ - protected function buildKeyValues(array $parts, string $format = '%s ', string $partSeparator = ', '): static + /** + * set(['col' => 123, 'other' => Sql::raw('NOW()')]) — parameterized pairs + * + * @param array> $pairs + */ + private function paramPairs(array $pairs): static { - foreach ($parts as $key => $part) { - if (is_numeric($key)) { - $parts[$key] = (string) $part; + $parts = []; + foreach ($pairs as $k => $v) { + if ($v instanceof self) { + $parts[] = $k . ' = ' . $this->absorb($v); } else { - $value = $part instanceof self ? (string) $part : self::PLACEHOLDER; - if (preg_match(self::SQL_OPERATORS, $key) > 0) { - $parts[$key] = $key . ' ' . $value; - } else { - $parts[$key] = $key . ' = ' . $value; - } + $parts[] = $k . ' = ?'; + $this->params[] = is_scalar($v) ? $v : null; } } - return $this->buildParts($parts, $format, $partSeparator); + $this->query[] = implode(', ', $parts); + + return $this; } - /** @param array $parts */ - protected function buildComparators(array $parts, string $format = '%s ', string $partSeparator = ', '): static + /** + * values([1, 2, null, Sql::raw('NOW()')]) — parenthesized placeholder list + * + * @param list> $values + */ + private function valueList(array $values): static { - foreach ($parts as $key => $part) { - if (is_numeric($key)) { - $parts[$key] = (string) $part; + $placeholders = []; + foreach ($values as $v) { + if ($v instanceof self) { + $placeholders[] = $this->absorb($v); } else { - $parts[$key] = $key . ' = ' . $part; + $placeholders[] = '?'; + $this->params[] = is_scalar($v) ? $v : null; } } - return $this->buildParts($parts, $format, $partSeparator); + $this->query[] = '(' . implode(', ', $placeholders) . ')'; + + return $this; } - /** @param array $parts */ - protected function buildAliases(array $parts, string $format = '%s ', string $partSeparator = ', '): static + /** + * where([['col','=','val'], 'AND', ['col2','IN',[1,2]]]) — triplet conditions + * + * Items are either operator strings ('AND', 'OR') or condition arrays: + * - array{string, string, scalar|null} scalar triplet + * - array{string, string, self} subquery triplet + * - array{string, string, list} expand triplet (IN, NOT IN, BETWEEN) + * - list<...> nested group (recursive) + * + * @param list> $items + */ + private function conditions(array $items): string { - foreach ($parts as $key => $part) { - if (is_numeric($key)) { - $parts[$key] = (string) $part; - } else { - $parts[$key] = $part . ' AS ' . $key; + $q = ''; + foreach ($items as $item) { + if (is_string($item)) { + $q .= ' ' . strtoupper($item) . ' '; + continue; } - } - return $this->buildParts($parts, $format, $partSeparator); - } + if (is_array($item) && count($item) === 3 && is_string($item[0]) && is_string($item[1])) { + $q .= $this->triplet($item[0], $item[1], $item[2]); + continue; + } - /** @param array $parts */ - protected function buildValuesList(array $parts): static - { - foreach ($parts as $key => $part) { - if (is_numeric($key) || $part instanceof self) { - $parts[$key] = (string) $part; - } else { - $parts[$key] = self::PLACEHOLDER; + if (is_array($item)) { + $q .= '(' . $this->conditions($item) . ')'; + continue; } } - return $this->buildParts($parts, '(%s) ', ', '); + return $q; } - protected function buildOperation(string $operation): void - { - $command = strtoupper(preg_replace('/[A-Z0-9]+/', ' $0', $operation)); - if ($command == '_') { - $this->query = rtrim($this->query) . ') '; - } elseif ($command[0] == '_') { - $this->query .= '(' . trim($command, '_ ') . ' '; - } elseif (substr($command, -1) == '_') { - $this->query .= trim($command, '_ ') . ' ('; - } else { - $this->query .= trim($command) . ' '; + /** + * ['col', '=', scalar|null|self|list] — single condition triplet + * + * @param list|scalar|self|null $value + */ + private function triplet( + string $column, + string $operator, + string|int|float|bool|self|array|null $value, + ): string { + if ($value === null) { + return $this->nullTriplet($column, $operator); } + + if ($value instanceof self) { + return $this->subqueryTriplet($column, $operator, $value); + } + + if (is_array($value)) { + return $this->expandTriplet($column, $operator, $value); + } + + return $this->scalarTriplet($column, $operator, $value); } - /** @param array $parts */ - protected function buildFirstPart(array &$parts): void + /** ['col', '=', null] → col IS NULL, ['col', '!=', null] → col IS NOT NULL */ + private function nullTriplet(string $column, string $operator): string { - $this->query .= array_shift($parts) . ' '; + return match ($operator) { + '=', '==' => $column . ' IS NULL', + '!=', '<>' => $column . ' IS NOT NULL', + default => throw new SqlException( + 'Operator \'' . $operator . '\' does not support null values', + ), + }; } - /** @param array $parts */ - protected function buildParts(array $parts, string $format = '%s ', string $partSeparator = ', '): static - { - if (!empty($parts)) { - $this->query .= sprintf($format, implode($partSeparator, $parts)); - } + /** ['col', '=', 'val'] — simple comparison with placeholder */ + private function scalarTriplet( + string $column, + string $operator, + string|int|float|bool $value, + ): string { + $this->params[] = $value; - return $this; + return $column . ' ' . $operator . ' ?'; + } + + /** ['col', '=', Sql::select(...)] — comparison against subquery */ + private function subqueryTriplet(string $column, string $operator, self $value): string + { + return $column . ' ' . $operator . ' ' . $this->absorb($value); } /** - * @param array $parts + * ['col', 'IN', [1,2,3]], ['col', 'NOT IN', [4,5]], or ['col', 'BETWEEN', [1, 100]] * - * @return array + * @param list $value */ - protected function normalizeParts(array $parts, bool $raw = false): array + private function expandTriplet(string $column, string $operator, array $value): string { - $params = & $this->params; - $newParts = []; - - array_walk_recursive($parts, static function ($value, $key) use (&$newParts, &$params, &$raw): void { - if ($value instanceof Sql) { - $params = array_merge($params, $value->getParams()); - if (stripos((string) $value, '(') !== 0) { - $value = Sql::enclose($value); - } - - $newParts[$key] = $value; - } elseif ($raw) { - $newParts[$key] = $value; - } elseif (is_int($key)) { - $newParts[] = $value; - } else { - $newParts[$key] = $key; - $params[] = $value; - } - }); + $op = strtoupper($operator); + + if (!isset(self::EXPAND[$op])) { + throw new SqlException( + 'Unsupported expand operator \'' . $op . '\', expected: ' + . implode(', ', array_keys(self::EXPAND)), + ); + } - return $newParts; + [$pre, $sep, $suf, $min, $max] = self::EXPAND[$op]; + $n = count($value); + if ($n < $min || ($max !== null && $n > $max)) { + $expected = $max === $min ? (string) $min : $min . '+'; + + throw new SqlException( + $op . ' requires ' . $expected . ' values, got ' . $n, + ); + } + + $placeholders = array_fill(0, count($value), '?'); + $this->params = array_merge($this->params, $value); + + return $column . ' ' . $op . ' ' . $pre . implode($sep, $placeholders) . $suf; } - /** @param array $parts */ - private function buildAlterTable(array $parts): static + private function absorb(self $sql): string { - $this->buildFirstPart($parts); + $this->params = array_merge($this->params, $sql->params); - return $this->buildParts($parts, '%s '); + return $sql->raw ? (string) $sql : '(' . $sql . ')'; } - /** @param array $parts */ - private function buildCreate(array $parts): static + /** @param array> $pair */ + private function alias(array $pair): string { - $this->params = []; - $this->buildFirstPart($parts); + $value = current($pair); + if ($value instanceof self) { + return $this->absorb($value) . ' AS ' . key($pair); + } - return $this->buildParts($parts, '(%s) '); + return (is_scalar($value) ? $value : '') . ' AS ' . key($pair); } - /** @param array $parts */ - public static function __callStatic(string $operation, array $parts): static + /** @param scalar|list|array>|self ...$names */ + private function formatList(string|int|float|bool|array|self ...$names): string { - $sql = new static(); + return implode(', ', array_map( + fn($name) => match (true) { + $name instanceof self => $this->absorb($name), + is_array($name) && array_is_list($name) => implode( + ' ', + array_filter($name, is_scalar(...)), + ), + is_array($name) => $this->alias($name), + default => $name, + }, + $names, + )); + } - return $sql->$operation(...$parts); + public function __toString(): string + { + return implode(' ', $this->query); } - /** @param array $parts */ - public function __call(string $operation, array $parts): static + /** + * @see self::__call() + * + * @param array>|self> $args + */ + public static function __callStatic(string $name, array $args): static { - return $this->preBuild($operation, $parts); + return (new static())->__call($name, $args); } - public function __toString(): string + /** + * Dispatches SQL clauses by detecting argument shapes: + * - string, ... comma list (select, from, orderBy) + * - string, list> name + columns (insertInto, createTable) + * - array raw pairs (on) + * - array parameterized pairs (set) + * - list value list (values) + * - list}|string|list<...>> + * conditions (where, having) + * + * @param array>|self> $args + */ + public function __call(string $name, array $args): static { - return rtrim($this->query); + $this->query[] = strtoupper(preg_replace('/[A-Z0-9]+/', ' $0', $name)); + + if (empty($args)) { + return $this; + } + + if (!is_array($args[0])) { + if (count($args) > 1 && is_array($args[1]) && array_is_list($args[1])) { + return $this->namedList((string) $args[0], $args[1]); + } + + return $this->commaList(...$args); + } + + if (!array_is_list($args[0])) { + if (in_array($name, self::RAW)) { + return $this->rawPairs($args[0]); + } + + return $this->paramPairs($args[0]); + } + + if (count($args[0]) < 1 || !is_array($args[0][0])) { + return $this->valueList($args[0]); + } + + $this->query[] = $this->conditions($args[0]); + + return $this; } } diff --git a/src/SqlException.php b/src/SqlException.php new file mode 100644 index 0000000..0845577 --- /dev/null +++ b/src/SqlException.php @@ -0,0 +1,11 @@ +object->select('*')->from('unit')->where(['testb' => 'abc'])->fetch(); + $line = $this->object->select('*')->from('unit') + ->where([['testb', '=', 'abc']])->fetch(); $this->assertEquals(10, $line->testa); } @@ -82,46 +83,50 @@ static function ($row) { public function testFetchingInto(): void { $x = new TestFetchingInto(); - $this->object->select('*')->from('unit')->where(['testb' => 'abc'])->fetch($x); + $this->object->select('*')->from('unit') + ->where([['testb', '=', 'abc']])->fetch($x); $this->assertEquals('abc', $x->testb); } - public function testRawSql(): void + public function testFluentSelect(): void { - $all = $this->object->query('select * from unit')->fetchAll(); + $all = $this->object->select('*')->from('unit')->fetchAll(); $this->assertEquals(3, count($all)); } public function testFetchingArray(): void { $line = $this->object->select('*')->from('unit') - ->where(['testb' => 'abc'])->fetch(PDO::FETCH_ASSOC); + ->where([['testb', '=', 'abc']])->fetch(PDO::FETCH_ASSOC); $this->assertTrue(is_array($line)); } public function testFetchingArray2(): void { - $line = $this->object->select('*')->from('unit')->where(['testb' => 'abc'])->fetch([]); + $line = $this->object->select('*')->from('unit') + ->where([['testb', '=', 'abc']])->fetch([]); $this->assertTrue(is_array($line)); } public function testGetSql(): void { - $sql = $this->object->select('*')->from('unit')->where(['testb' => 'abc'])->getSql(); + $sql = $this->object->select('*')->from('unit') + ->where([['testb', '=', 'abc']])->getSql(); $this->assertEquals('SELECT * FROM unit WHERE testb = ?', (string) $sql); $this->assertEquals(['abc'], $sql->getParams()); } - public function testRawSqlWithParams(): void + public function testFluentSelectWithParams(): void { - $line = $this->object->query('SELECT * FROM unit WHERE testb = ?', ['abc'])->fetch(); + $line = $this->object->select('*')->from('unit') + ->where([['testb', '=', 'abc']])->fetch(); $this->assertEquals(10, $line->testa); } public function testExecReturnsTrueOnSuccess(): void { - $result = $this->object->insertInto('unit', ['testa' => 40, 'testb' => 'jkl']) - ->values(['testa' => 40, 'testb' => 'jkl']) + $result = $this->object->insertInto('unit', ['testa', 'testb']) + ->values([40, 'jkl']) ->exec(); $this->assertTrue($result); } diff --git a/tests/MapperTest.php b/tests/MapperTest.php index b2e313a..b9fe0d9 100644 --- a/tests/MapperTest.php +++ b/tests/MapperTest.php @@ -20,7 +20,9 @@ use Throwable; use TypeError; +use function array_keys; use function array_reverse; +use function array_values; use function count; use function current; use function date; @@ -149,27 +151,33 @@ protected function setUp(): void ]; foreach ($this->authors as $author) { - $db->insertInto('author', (array) $author)->values((array) $author)->exec(); + $cols = (array) $author; + $db->insertInto('author', array_keys($cols))->values(array_values($cols))->exec(); } foreach ($this->posts as $post) { - $db->insertInto('post', (array) $post)->values((array) $post)->exec(); + $cols = (array) $post; + $db->insertInto('post', array_keys($cols))->values(array_values($cols))->exec(); } foreach ($this->comments as $comment) { - $db->insertInto('comment', (array) $comment)->values((array) $comment)->exec(); + $cols = (array) $comment; + $db->insertInto('comment', array_keys($cols))->values(array_values($cols))->exec(); } foreach ($this->categories as $category) { - $db->insertInto('category', (array) $category)->values((array) $category)->exec(); + $cols = (array) $category; + $db->insertInto('category', array_keys($cols))->values(array_values($cols))->exec(); } foreach ($this->postsCategories as $postCategory) { - $db->insertInto('post_category', (array) $postCategory)->values((array) $postCategory)->exec(); + $cols = (array) $postCategory; + $db->insertInto('post_category', array_keys($cols))->values(array_values($cols))->exec(); } foreach ($this->issues as $issue) { - $db->insertInto('issues', (array) $issue)->values((array) $issue)->exec(); + $cols = (array) $issue; + $db->insertInto('issues', array_keys($cols))->values(array_values($cols))->exec(); } $mapper = new Mapper($conn); @@ -1144,11 +1152,11 @@ public function testShouldNotExecuteEntityConstructorWhenDisabled(): void ); } - public function testFetchWithStringConditionUsingColumnExpression(): void + public function testFetchWithConditionUsingColumnValue(): void { $mapper = $this->mapper; - $comments = $mapper->comment(['comment.id > 0'])->fetchAll(); - $this->assertCount(2, $comments); + $comments = $mapper->comment(['post_id' => 5])->fetchAll(); + $this->assertCount(1, $comments); } public function testPersistNewEntityWithNoAutoIncrementId(): void diff --git a/tests/SqlTest.php b/tests/SqlTest.php index f92cc8d..d8a054d 100644 --- a/tests/SqlTest.php +++ b/tests/SqlTest.php @@ -8,10 +8,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use function array_merge; -use function array_values; - #[CoversClass(Sql::class)] +#[CoversClass(SqlException::class)] class SqlTest extends TestCase { protected Sql $object; @@ -27,7 +25,6 @@ public function testCastingObjectToStringReturnsQuery(): void $query = 'SELECT * FROM table'; $this->assertNotSame($query, $sql); $this->assertSame($query, (string) $sql); - $this->assertSame($query, (string) $sql); } public function testSimpleSelect(): void @@ -54,18 +51,29 @@ public function testSelectTables(): void $this->assertEquals('SELECT * FROM table, other_table', $sql); } - public function testSelectInnerJoin(): void + public function testSelectUsingAliasedColumns(): void { - $sql = (string) $this->object->select('*')->from('table') - ->innerJoin('other_table') - ->on('table.column = other_table.other_column'); + $sql = (string) $this->object->select( + 'f1', + ['alias' => 'f2'], + 'f3', + ['another_alias' => 'f4'], + )->from('table'); $this->assertEquals( - 'SELECT * FROM table INNER JOIN other_table ON table.column = other_table.other_column', + 'SELECT f1, f2 AS alias, f3, f4 AS another_alias FROM table', $sql, ); + $this->assertEmpty($this->object->getParams()); } - public function testSelectInnerJoinArr(): void + public function testSelectWithAggregateFunctions(): void + { + $sql = (string) $this->object->select('column', 'COUNT(column)', 'SUM(amount)') + ->from('table'); + $this->assertEquals('SELECT column, COUNT(column), SUM(amount) FROM table', $sql); + } + + public function testSelectInnerJoin(): void { $sql = (string) $this->object->select('*')->from('table') ->innerJoin('other_table') @@ -76,72 +84,139 @@ public function testSelectInnerJoinArr(): void ); } - public function testSelectWhere(): void - { - $sql = (string) $this->object->select('*')->from('table')->where('column=123'); - $this->assertEquals('SELECT * FROM table WHERE column=123', $sql); - } - - public function testSelectWhereBetween(): void + public function testWhereWithAndConditions(): void { - $sql = (string) $this->object->select('*')->from('table')->where('column')->between(1, 2); - $this->assertEquals('SELECT * FROM table WHERE column BETWEEN 1 AND 2', $sql); + $sql = (string) $this->object->select('*')->from('table')->where([ + ['column', '=', '123'], + 'AND', + ['other_column', '=', '456'], + ]); + $this->assertEquals( + 'SELECT * FROM table WHERE column = ? AND other_column = ?', + $sql, + ); + $this->assertEquals(['123', '456'], $this->object->getParams()); } - public function testSelectWhereIn(): void + public function testWhereWithOrConditions(): void { - $data = ['key' => '123', 'other_key' => '456']; - $sql = (string) $this->object->select('*')->from('table')->where('column')->in($data); - $this->assertEquals('SELECT * FROM table WHERE column IN (?, ?)', $sql); - $this->assertEquals(array_values($data), $this->object->getParams()); + $sql = (string) $this->object->select('*')->from('table')->where([ + ['column', '=', '123'], + 'OR', + ['other_column', '=', '456'], + ]); + $this->assertEquals( + 'SELECT * FROM table WHERE column = ? OR other_column = ?', + $sql, + ); + $this->assertEquals(['123', '456'], $this->object->getParams()); } - public function testSelectWhereArray(): void + public function testWhereWithNestedGroupedConditions(): void { - $data = ['column' => '123', 'other_column' => '456']; - $sql = (string) $this->object->select('*')->from('table')->where($data); + $sql = (string) $this->object->select('*')->from('table')->where([ + ['foo', '=', 'baz'], + 'OR', + [ + ['xoo', '=', 'qux'], + 'AND', + ['xoo', '=', 'zap'], + ], + ]); $this->assertEquals( - 'SELECT * FROM table WHERE column = ? AND other_column = ?', + 'SELECT * FROM table WHERE foo = ? OR (xoo = ? AND xoo = ?)', $sql, ); - $this->assertEquals(array_values($data), $this->object->getParams()); + $this->assertEquals(['baz', 'qux', 'zap'], $this->object->getParams()); } - public function testSelectWhereArrayEmptyAnd(): void + public function testSelectWhereArrayQualifiedNames(): void { - $data = ['column' => '123', 'other_column' => '456']; - $sql = (string) $this->object->select('*')->from('table')->where($data)->and(); + $sql = (string) $this->object->select('*') + ->from('table a', 'other_table b') + ->where([ + ['a.column', '=', '123'], + 'AND', + ['b.other_column', '=', '456'], + ]); $this->assertEquals( - 'SELECT * FROM table WHERE column = ? AND other_column = ?', + 'SELECT * FROM table a, other_table b WHERE a.column = ? AND b.other_column = ?', $sql, ); - $this->assertEquals(array_values($data), $this->object->getParams()); + $this->assertEquals(['123', '456'], $this->object->getParams()); + } + + public function testWhereIn(): void + { + $data = ['123', '456']; + $sql = (string) $this->object->select('*')->from('table') + ->where([['column', 'IN', $data]]); + $this->assertEquals('SELECT * FROM table WHERE column IN (?, ?)', $sql); + $this->assertEquals($data, $this->object->getParams()); + } + + public function testWhereBetween(): void + { + $sql = (string) $this->object->select('*')->from('table') + ->where([['column', 'BETWEEN', [1, 100]]]); + $this->assertEquals('SELECT * FROM table WHERE column BETWEEN ? AND ?', $sql); + $this->assertEquals([1, 100], $this->object->getParams()); } - public function testSelectWhereOr(): void + public function testWhereInWithCompoundConditions(): void { - $data = ['column' => '123']; - $data2 = ['other_column' => '456']; - $sql = (string) $this->object->select('*')->from('table')->where($data)->or($data2); + $sql = (string) $this->object->select('*')->from('table')->where([ + ['column', 'IN', ['a', 'b', 'c']], + 'AND', + ['other', '=', 'foo'], + ]); $this->assertEquals( - 'SELECT * FROM table WHERE column = ? OR other_column = ?', + 'SELECT * FROM table WHERE column IN (?, ?, ?) AND other = ?', $sql, ); - $this->assertEquals( - array_values(array_merge($data, $data2)), - $this->object->getParams(), - ); + $this->assertEquals(['a', 'b', 'c', 'foo'], $this->object->getParams()); } - public function testSelectWhereArrayQualifiedNames(): void + /** @return array> */ + public static function providerSqlOperators(): array + { + return [ + ['='], + ['=='], + ['<>'], + ['!='], + ['>'], + ['>='], + ['<'], + ['<='], + ['LIKE'], + ['NOT LIKE'], + ]; + } + + #[DataProvider('providerSqlOperators')] + public function testAllComparisonOperators(string $operator): void { - $data = ['a.column' => '123', 'b.other_column' => '456']; - $sql = (string) $this->object->select('*')->from('table a', 'other_table b')->where($data); + $sql = (string) $this->object->select('*')->from('table') + ->where([['id', $operator, '10']]); + $this->assertEquals('SELECT * FROM table WHERE id ' . $operator . ' ?', $sql); + $this->assertEquals(['10'], $this->object->getParams()); + } + + public function testMixedOperatorsInCompoundConditions(): void + { + $sql = (string) $this->object->select('*')->from('table')->where([ + ['age', '>=', '18'], + 'AND', + ['name', 'LIKE', '%foo%'], + 'AND', + ['status', '!=', 'banned'], + ]); $this->assertEquals( - 'SELECT * FROM table a, other_table b WHERE a.column = ? AND b.other_column = ?', + 'SELECT * FROM table WHERE age >= ? AND name LIKE ? AND status != ?', $sql, ); - $this->assertEquals(array_values($data), $this->object->getParams()); + $this->assertEquals(['18', '%foo%', 'banned'], $this->object->getParams()); } public function testSelectGroupBy(): void @@ -150,391 +225,414 @@ public function testSelectGroupBy(): void $this->assertEquals('SELECT * FROM table GROUP BY column, other_column', $sql); } - public function testSelectGroupByHaving(): void + public function testGroupByWithHaving(): void { - $condition = ['other_column' => 456, 'yet_another_column' => 567]; $sql = (string) $this->object->select('*')->from('table') - ->groupBy('column', 'other_column')->having($condition); + ->groupBy('column', 'other_column') + ->having([ + ['other_column', '=', 456], + 'AND', + ['yet_another_column', '=', 567], + ]); $this->assertEquals( 'SELECT * FROM table GROUP BY column, other_column' . ' HAVING other_column = ? AND yet_another_column = ?', $sql, ); - $this->assertEquals(array_values($condition), $this->object->getParams()); + $this->assertEquals([456, 567], $this->object->getParams()); + } + + public function testHavingWithAggregateOperators(): void + { + $sql = (string) $this->object->select('column', 'MAX(def)')->from('table') + ->where([['abc', '=', '10']]) + ->groupBy('abc', 'def') + ->having([ + ['SUM(abc)', '>=', '10'], + 'AND', + ['AVG(def)', '=', 15], + ]); + $this->assertEquals( + 'SELECT column, MAX(def) FROM table WHERE abc = ?' + . ' GROUP BY abc, def HAVING SUM(abc) >= ? AND AVG(def) = ?', + $sql, + ); + $this->assertEquals(['10', '10', 15], $this->object->getParams()); } public function testSimpleUpdate(): void { $data = ['column' => 123, 'column_2' => 234]; - $condition = ['other_column' => 456, 'yet_another_column' => 567]; - $sql = (string) $this->object->update('table')->set($data)->where($condition); + $sql = (string) $this->object->update('table')->set($data)->where([ + ['other_column', '=', 456], + 'AND', + ['yet_another_column', '=', 567], + ]); $this->assertEquals( 'UPDATE table SET column = ?, column_2 = ?' . ' WHERE other_column = ? AND yet_another_column = ?', $sql, ); + $this->assertEquals([123, 234, 456, 567], $this->object->getParams()); + } + + public function testSetWithRawExpression(): void + { + $sql = (string) $this->object->update('table') + ->set(['counter' => Sql::raw('counter + 1'), 'updated_at' => Sql::raw('NOW()')]) + ->where([['id', '=', '5']]); $this->assertEquals( - array_values(array_merge($data, $condition)), - $this->object->getParams(), + 'UPDATE table SET counter = counter + 1, updated_at = NOW() WHERE id = ?', + $sql, + ); + $this->assertEquals(['5'], $this->object->getParams()); + } + + public function testSetWithSubquery(): void + { + $sub = Sql::select('MAX(score)')->from('scores')->where([['active', '=', '1']]); + $sql = (string) $this->object->update('table') + ->set(['high_score' => $sub]) + ->where([['id', '=', '5']]); + $this->assertEquals( + 'UPDATE table SET high_score = (SELECT MAX(score) FROM scores WHERE active = ?) WHERE id = ?', + $sql, ); + $this->assertEquals(['1', '5'], $this->object->getParams()); } public function testSimpleInsert(): void { - $data = ['column' => 123, 'column_2' => 234]; - $sql = (string) $this->object->insertInto('table', $data)->values($data); + $sql = (string) $this->object->insertInto('table', ['column', 'column_2']) + ->values([123, 234]); $this->assertEquals('INSERT INTO table (column, column_2) VALUES (?, ?)', $sql); - $this->assertEquals(array_values($data), $this->object->getParams()); + $this->assertEquals([123, 234], $this->object->getParams()); } - public function testSimpleDelete(): void + public function testInsertWithRawValues(): void { - $condition = ['other_column' => 456, 'yet_another_column' => 567]; - $sql = (string) $this->object->deleteFrom('table')->where($condition); + $sql = (string) $this->object->insertInto('table', ['column', 'column_2', 'date']) + ->values([123, 234, Sql::raw('NOW()')]); $this->assertEquals( - 'DELETE FROM table WHERE other_column = ? AND yet_another_column = ?', + 'INSERT INTO table (column, column_2, date) VALUES (?, ?, NOW())', $sql, ); - $this->assertEquals(array_values($condition), $this->object->getParams()); + $this->assertEquals([123, 234], $this->object->getParams()); } - public function testCreateTable(): void + public function testInsertWithSelectSubquery(): void { - $columns = [ - 'column INT', - 'other_column VARCHAR(255)', - 'yet_another_column TEXT', - ]; - $sql = (string) $this->object->createTable('table', $columns); + $subquery = Sql::select('f1', 'f2')->from('t2')->where([ + ['f3', '=', 3], + 'AND', + ['f4', '=', 4], + ]); + $sql = (string) $this->object->insertInto('t1', ['f1', 'f2'])->concat($subquery); + $this->assertEquals( - 'CREATE TABLE table (column INT, other_column VARCHAR(255), yet_another_column TEXT)', + 'INSERT INTO t1 (f1, f2) SELECT f1, f2 FROM t2 WHERE f3 = ? AND f4 = ?', $sql, ); + $this->assertEquals([3, 4], $this->object->getParams()); } - public function testAlterTable(): void + public function testSimpleDelete(): void { - $columns = [ - 'ADD column INT', - 'ADD other_column VARCHAR(255)', - 'ADD yet_another_column TEXT', - ]; - $sql = (string) $this->object->alterTable('table', $columns); + $sql = (string) $this->object->deleteFrom('table')->where([ + ['other_column', '=', 456], + 'AND', + ['yet_another_column', '=', 567], + ]); $this->assertEquals( - 'ALTER TABLE table ADD column INT, ADD other_column VARCHAR(255), ADD yet_another_column TEXT', + 'DELETE FROM table WHERE other_column = ? AND yet_another_column = ?', $sql, ); + $this->assertEquals([456, 567], $this->object->getParams()); } - public function testGrant(): void + public function testWhereWithSubquery(): void { - $sql = (string) $this->object->grant('SELECT', 'UPDATE')->on('table')->to('user', 'other_user'); - $this->assertEquals('GRANT SELECT, UPDATE ON table TO user, other_user', $sql); - } + $subquery = Sql::select('column1')->from('t2')->where([['column2', '=', 2]]); + $sql = (string) $this->object->select('column1')->from('t1') + ->where([['column1', '=', $subquery], 'AND', ['column2', '=', 'foo']]); - public function testRevoke(): void - { - $sql = (string) $this->object->revoke('SELECT', 'UPDATE')->on('table')->to('user', 'other_user'); - $this->assertEquals('REVOKE SELECT, UPDATE ON table TO user, other_user', $sql); + $this->assertEquals( + 'SELECT column1 FROM t1 WHERE column1 = (SELECT column1 FROM t2 WHERE column2 = ?)' + . ' AND column2 = ?', + $sql, + ); + $this->assertEquals([2, 'foo'], $this->object->getParams()); } - public function testComplexFunctions(): void + public function testNestedSubqueries(): void { - $condition = ["AES_DECRYPT('pass', 'salt')" => 123]; - $sql = (string) $this->object->select('column', 'COUNT(column)', 'other_column') - ->from('table')->where($condition); + $subquery1 = Sql::select('column1')->from('t3')->where([['column3', '=', 3]]); + $subquery2 = Sql::select('column1')->from('t2') + ->where([['column2', '=', $subquery1], 'AND', ['column3', '=', 'foo']]); + $sql = (string) $this->object->select('column1')->from('t1') + ->where([['column1', '=', $subquery2]]); + $this->assertEquals( - "SELECT column, COUNT(column), other_column FROM table WHERE AES_DECRYPT('pass', 'salt') = ?", + 'SELECT column1 FROM t1 WHERE column1 = (SELECT column1 FROM t2' + . ' WHERE column2 = (SELECT column1 FROM t3 WHERE column3 = ?) AND column3 = ?)', $sql, ); - $this->assertEquals(array_values($condition), $this->object->getParams()); + $this->assertEquals([3, 'foo'], $this->object->getParams()); } - /** @ticket 13 */ - public function testAggregateFunctions(): void + public function testSelectColumnAsSubquery(): void { - $where = ['abc' => 10]; - $having = ['SUM(abc) >=' => '10', 'AVG(def) =' => 15]; - $sql = (string) $this->object->select('column', 'MAX(def)')->from('table') - ->where($where)->groupBy('abc', 'def')->having($having); + $subquery = Sql::select('f1')->from('t2')->where([['f2', '=', 2]]); + $sql = (string) $this->object->select('f1', ['subalias' => $subquery]) + ->from('t1')->where([['f2', '=', 'foo']]); + $this->assertEquals( - 'SELECT column, MAX(def) FROM table WHERE abc = ?' - . ' GROUP BY abc, def HAVING SUM(abc) >= ? AND AVG(def) = ?', + 'SELECT f1, (SELECT f1 FROM t2 WHERE f2 = ?) AS subalias FROM t1 WHERE f2 = ?', $sql, ); - $this->assertEquals( - array_values(array_merge($where, $having)), - $this->object->getParams(), - ); + $this->assertEquals([2, 'foo'], $this->object->getParams()); } - public function testStaticBuilderCall(): void + public function testWhereWithFunctionColumn(): void { + $sql = (string) $this->object->select('column', 'COUNT(column)', 'other_column') + ->from('table')->where([["AES_DECRYPT('pass', 'salt')", '=', 123]]); $this->assertEquals( - 'ORDER BY updated_at DESC', - (string) Sql::orderBy('updated_at')->desc(), + "SELECT column, COUNT(column), other_column FROM table WHERE AES_DECRYPT('pass', 'salt') = ?", + $sql, ); + $this->assertEquals([123], $this->object->getParams()); } - public function testLastParameterWithoutParts(): void + public function testCreateTable(): void { + $sql = (string) $this->object->createTable('users', [ + ['id', 'INT', 'PRIMARY KEY'], + ['name', 'VARCHAR(255)'], + ['email', 'VARCHAR(255)'], + ]); $this->assertEquals( - 'ORDER BY updated_at DESC', - $this->object->orderBy('updated_at')->desc(), + 'CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(255), email VARCHAR(255))', + $sql, ); } - /** @return array> */ - public static function providerSqlOperators(): array + public function testCreateTableIfNotExists(): void { - // operator, expectedWhere - return [ - ['='], - ['=='], - ['<>'], - ['!='], - ['>'], - ['>='], - ['<'], - ['<='], - ['LIKE'], - ]; + $sql = (string) Sql::createTableIfNotExists('users', [['id', 'INT', 'PRIMARY KEY']]); + $this->assertEquals('CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY)', $sql); } - /** @ticket 13 */ - #[DataProvider('providerSqlOperators')] - public function testSqlOperators(string $operator, string|null $expected = null): void + public function testCreateTableWithConstraints(): void { - $expected = $expected ?: ' ?'; - $where = ['id ' . $operator => 10]; - $sql = (string) $this->object->select('*')->from('table')->where($where); - $this->assertEquals('SELECT * FROM table WHERE id ' . $operator . $expected, $sql); + $sql = (string) Sql::createTable('posts', [ + ['id', 'INT', 'PRIMARY KEY'], + ['author_id', 'INT', 'NOT NULL'], + 'FOREIGN KEY (author_id) REFERENCES authors(id)', + ]); + $this->assertEquals( + 'CREATE TABLE posts (id INT PRIMARY KEY, author_id INT NOT NULL,' + . ' FOREIGN KEY (author_id) REFERENCES authors(id))', + $sql, + ); } - public function testSetQueryWithParams(): void + public function testCreateTableWithEngineViaConcat(): void { - $query = 'SELECT * FROM table WHERE a > ? AND b = ?'; - $params = [1, 'foo']; - - $sql = (string) $this->object->setQuery($query, $params); - $this->assertEquals($query, $sql); - $this->assertEquals($params, $this->object->getParams()); - - $sql = (string) $this->object->setQuery('', []); - $this->assertEmpty($sql); - $this->assertEmpty($this->object->getParams()); + $sql = (string) Sql::createTable('users', [ + ['id', 'INT', 'PRIMARY KEY', 'AUTO_INCREMENT'], + ['name', 'VARCHAR(255)', 'NOT NULL'], + ])->concat(Sql::raw('ENGINE=InnoDB DEFAULT CHARSET=utf8mb4')); + $this->assertEquals( + 'CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL)' + . ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $sql, + ); } - public function testSetQueryWithParamsViaConstructor(): void + public function testDropTable(): void { - $query = 'SELECT * FROM table WHERE a > ? AND b = ?'; - $params = [1, 'foo']; - - $sql = new Sql($query, $params); - $this->assertEquals($query, (string) $sql); - $this->assertEquals($params, $sql->getParams()); + $sql = (string) Sql::dropTable('users'); + $this->assertEquals('DROP TABLE users', $sql); } - public function testAppendQueryWithParams(): void + public function testDropTableIfExists(): void { - $query = 'SELECT * FROM table WHERE a > ? AND b = ?'; - $this->object->setQuery( - 'SELECT * FROM table WHERE a > ? AND b = ?', - [1, 'foo'], - ); - $sql = (string) $this->object->appendQuery('AND c = ?', [2]); - - $this->assertEquals($query . ' AND c = ?', $sql); - $this->assertEquals([1, 'foo', 2], $this->object->getParams()); + $sql = (string) Sql::dropTableIfExists('users'); + $this->assertEquals('DROP TABLE IF EXISTS users', $sql); } - public function testSelectWhereWithRepeatedReferences(): void + public function testTruncateTable(): void { - $data1 = ['a >' => 1, 'b' => 'foo']; - $data2 = ['a >' => 4]; - $data3 = ['b' => 'bar']; - - $sql = (string) $this->object->select('*')->from('table') - ->where($data1)->or($data2)->and($data3); - $this->assertEquals( - 'SELECT * FROM table WHERE a > ? AND b = ? OR a > ? AND b = ?', - $sql, - ); - $this->assertEquals([1, 'foo', 4, 'bar'], $this->object->getParams()); + $sql = (string) Sql::truncateTable('users'); + $this->assertEquals('TRUNCATE TABLE users', $sql); } - public function testSelectWhereWithConditionsGroupedByUnderscores(): void + public function testAlterTableChained(): void { - $data = [['a' => 1], ['b' => 2], ['c' => 3], ['d' => 4]]; - - $sql = (string) $this->object->select('*')->from('table') - ->where($data[0])->and_($data[1])->or($data[2])->_(); + $sql = (string) Sql::alterTable('users') + ->addColumn('email VARCHAR(255)') + ->addColumn('age INT') + ->dropColumn('old_col'); $this->assertEquals( - 'SELECT * FROM table WHERE a = ? AND (b = ? OR c = ?)', + 'ALTER TABLE users ADD COLUMN email VARCHAR(255) ADD COLUMN age INT DROP COLUMN old_col', $sql, ); - $this->assertEquals([1, 2, 3], $this->object->getParams()); - $this->object->setQuery('', []); + } - $sql = (string) $this->object->select('*')->from('table') - ->where_($data[0])->or($data[1])->_() - ->and_($data[2])->or($data[3])->_(); - $this->assertEquals( - 'SELECT * FROM table WHERE (a = ? OR b = ?) AND (c = ? OR d = ?)', - $sql, - ); - $this->assertEquals([1, 2, 3, 4], $this->object->getParams()); - $this->object->setQuery('', []); + public function testCreateIndex(): void + { + $sql = (string) Sql::createIndex('idx_email')->on('users', ['email']); + $this->assertEquals('CREATE INDEX idx_email ON users (email)', $sql); + } - $sql = (string) $this->object->select('*')->from('table') - ->where($data[0])->and_($data[1])->or_($data[2])->and($data[3])->_()->_(); + public function testCreateUniqueIndex(): void + { + $sql = (string) Sql::createUniqueIndex('idx_email')->on('users', ['email', 'tenant_id']); $this->assertEquals( - 'SELECT * FROM table WHERE a = ? AND (b = ? OR (c = ? AND d = ?))', + 'CREATE UNIQUE INDEX idx_email ON users (email, tenant_id)', $sql, ); - $this->assertEquals([1, 2, 3, 4], $this->object->getParams()); } - public function testSelectWhereWithConditionsGroupedBySubqueries(): void + public function testGrant(): void { - $data = [['a' => 1], ['b' => 2], ['c' => 3], ['d' => 4]]; + $sql = (string) $this->object->grant('SELECT', 'UPDATE')->on('table')->to('user', 'other_user'); + $this->assertEquals('GRANT SELECT, UPDATE ON table TO user, other_user', $sql); + } - $sql = (string) $this->object->select('*')->from('table') - ->where($data[0], Sql::cond($data[1])->or($data[2])); - $this->assertEquals( - 'SELECT * FROM table WHERE a = ? AND (b = ? OR c = ?)', - $sql, - ); - $this->assertEquals([1, 2, 3], $this->object->getParams()); - $this->object->setQuery('', []); + public function testRevoke(): void + { + $sql = (string) $this->object->revoke('SELECT', 'UPDATE')->on('table')->to('user', 'other_user'); + $this->assertEquals('REVOKE SELECT, UPDATE ON table TO user, other_user', $sql); + } - $sql = (string) $this->object->select('*')->from('table') - ->where(Sql::cond($data[0])->or($data[1]), Sql::cond($data[2])->or($data[3])); + public function testStaticBuilderCall(): void + { $this->assertEquals( - 'SELECT * FROM table WHERE (a = ? OR b = ?) AND (c = ? OR d = ?)', - $sql, + 'ORDER BY updated_at DESC', + (string) Sql::orderBy('updated_at')->desc(), ); - $this->assertEquals([1, 2, 3, 4], $this->object->getParams()); - $this->object->setQuery('', []); + } - $sql = (string) $this->object->select('*')->from('table') - ->where($data[0], Sql::cond($data[1])->or(Sql::cond($data[2], $data[3]))); + public function testLastParameterWithoutParts(): void + { $this->assertEquals( - 'SELECT * FROM table WHERE a = ? AND (b = ? OR (c = ? AND d = ?))', - $sql, + 'ORDER BY updated_at DESC', + (string) $this->object->orderBy('updated_at')->desc(), ); - $this->assertEquals([1, 2, 3, 4], $this->object->getParams()); } - public function testSelectWhereWithSubquery(): void + public function testConcat(): void { - $subquery = Sql::select('column1')->from('t2')->where(['column2' => 2]); - $sql = (string) $this->object->select('column1')->from('t1') - ->where(['column1' => $subquery, 'column2' => 'foo']); + $base = Sql::select('*')->from('table')->where([['a', '=', 1]]); + $extra = Sql::orderBy('b')->desc(); + $base->concat($extra); $this->assertEquals( - 'SELECT column1 FROM t1 WHERE column1 = (SELECT column1 FROM t2 WHERE column2 = ?)' - . ' AND column2 = ?', - $sql, + 'SELECT * FROM table WHERE a = ? ORDER BY b DESC', + (string) $base, ); - $this->assertEquals([2, 'foo'], $this->object->getParams()); + $this->assertEquals([1], $base->getParams()); } - public function testSelectWhereWithNestedSubqueries(): void + public function testConcatMergesParams(): void { - $subquery1 = Sql::select('column1')->from('t3')->where(['column3' => 3]); - $subquery2 = Sql::select('column1')->from('t2') - ->where(['column2' => $subquery1, 'column3' => 'foo']); - $sql = (string) $this->object->select('column1')->from('t1') - ->where(['column1' => $subquery2]); + $base = Sql::select('*')->from('t1')->where([['a', '=', 1]]); + $extra = Sql::select('*')->from('t2')->where([['b', '=', 2]]); + $base->concat($extra); - $this->assertEquals( - 'SELECT column1 FROM t1 WHERE column1 = (SELECT column1 FROM t2' - . ' WHERE column2 = (SELECT column1 FROM t3 WHERE column3 = ?) AND column3 = ?)', - $sql, - ); - $this->assertEquals([3, 'foo'], $this->object->getParams()); + $this->assertEquals([1, 2], $base->getParams()); } - public function testSelectUsingAliasedColumns(): void + public function testWhereWithNullValue(): void { - $sql = (string) $this->object->select( - 'f1', - ['alias' => 'f2'], - 'f3', - ['another_alias' => 'f4'], - )->from('table'); - $this->assertEquals( - 'SELECT f1, f2 AS alias, f3, f4 AS another_alias FROM table', - $sql, - ); + $sql = (string) $this->object->select('*')->from('table') + ->where([['col', '=', null]]); + $this->assertEquals('SELECT * FROM table WHERE col IS NULL', $sql); $this->assertEmpty($this->object->getParams()); } - public function testSelectWithColumnAsSubquery(): void + public function testWhereWithNotEqualNull(): void { - $subquery = Sql::select('f1')->from('t2')->where(['f2' => 2]); - $sql = (string) $this->object->select('f1', ['subalias' => $subquery]) - ->from('t1')->where(['f2' => 'foo']); + $sql = (string) $this->object->select('*')->from('table') + ->where([['col', '!=', null]]); + $this->assertEquals('SELECT * FROM table WHERE col IS NOT NULL', $sql); + $this->assertEmpty($this->object->getParams()); + } - $this->assertEquals( - 'SELECT f1, (SELECT f1 FROM t2 WHERE f2 = ?) AS subalias FROM t1 WHERE f2 = ?', - $sql, - ); - $this->assertEquals([2, 'foo'], $this->object->getParams()); + public function testWhereWithNotEqualNullDiamondOperator(): void + { + $sql = (string) $this->object->select('*')->from('table') + ->where([['col', '<>', null]]); + $this->assertEquals('SELECT * FROM table WHERE col IS NOT NULL', $sql); } - public function testInsertWithValueFunctions(): void + public function testWhereWithNullInCompoundCondition(): void { - $data = ['column' => 123, 'column_2' => 234]; - $sql = (string) $this->object->insertInto('table', $data, 'date')->values($data, 'NOW()'); + $sql = (string) $this->object->select('*')->from('table')->where([ + ['name', '=', 'foo'], + 'AND', + ['deleted_at', '=', null], + ]); $this->assertEquals( - 'INSERT INTO table (column, column_2, date) VALUES (?, ?, NOW())', + 'SELECT * FROM table WHERE name = ? AND deleted_at IS NULL', $sql, ); - $this->assertEquals(array_values($data), $this->object->getParams()); + $this->assertEquals(['foo'], $this->object->getParams()); } - public function testInsertWithSelectSubquery(): void + public function testInsertWithNullValue(): void { - $data = ['f3' => 3, 'f4' => 4]; - $subquery = Sql::select('f1', 'f2')->from('t2')->where($data); - $sql = (string) $this->object->insertInto('t1', ['f1', 'f2'])->appendQuery($subquery); + $sql = (string) $this->object->insertInto('table', ['a', 'b', 'c']) + ->values([1, null, 'foo']); + $this->assertEquals('INSERT INTO table (a, b, c) VALUES (?, ?, ?)', $sql); + $this->assertEquals([1, null, 'foo'], $this->object->getParams()); + } + public function testSetWithNullValue(): void + { + $sql = (string) $this->object->update('table') + ->set(['col' => null, 'other' => 123]) + ->where([['id', '=', 1]]); $this->assertEquals( - 'INSERT INTO t1 (f1, f2) SELECT f1, f2 FROM t2 WHERE f3 = ? AND f4 = ?', + 'UPDATE table SET col = ?, other = ? WHERE id = ?', $sql, ); - $this->assertEquals(array_values($data), $this->object->getParams()); + $this->assertEquals([null, 123, 1], $this->object->getParams()); } - public function testEncloseWithStringWrapsInParentheses(): void + public function testNullWithUnsupportedOperatorThrows(): void { - $result = Sql::enclose('SELECT 1'); - $this->assertEquals('(SELECT 1) ', $result); + $this->expectException(SqlException::class); + $this->expectExceptionMessage('does not support null'); + (string) Sql::select('*')->from('table')->where([['col', '>', null]]); } - public function testEncloseWithEmptyStringReturnsEmpty(): void + public function testExpandWithUnsupportedOperatorThrows(): void { - $result = Sql::enclose(''); - $this->assertEquals('', $result); + $this->expectException(SqlException::class); + $this->expectExceptionMessage('Unsupported expand operator'); + (string) Sql::select('*')->from('table')->where([['col', 'LIKE', [1, 2]]]); } - public function testEncloseWithSqlObjectWrapsQuery(): void + public function testBetweenWithWrongArityThrows(): void { - $sql = new Sql('SELECT 1'); - $result = Sql::enclose($sql); - $this->assertInstanceOf(Sql::class, $result); - $this->assertEquals('(SELECT 1)', (string) $result); + $this->expectException(SqlException::class); + $this->expectExceptionMessage('BETWEEN requires 2 values'); + (string) Sql::select('*')->from('table')->where([['col', 'BETWEEN', [1]]]); } - public function testBuildOperationWithLeadingUnderscore(): void + public function testInWithEmptyListThrows(): void { - $sql = (string) $this->object->select('*')->from('table') - ->where('column > 1')->_innerSelect('f1')->from('t2')->_(); - $this->assertEquals( - 'SELECT * FROM table WHERE column > 1 (INNER SELECT f1 FROM t2)', - $sql, - ); + $this->expectException(SqlException::class); + $this->expectExceptionMessage('requires 1+ values'); + (string) Sql::select('*')->from('table')->where([['col', 'IN', []]]); + } + + public function testNotInWithEmptyListThrows(): void + { + $this->expectException(SqlException::class); + (string) Sql::select('*')->from('table')->where([['col', 'NOT IN', []]]); } }