Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/Phaseolies/Database/Entity/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ public function select(array|string ...$fields): self
*/
public function where($field, $operator = null, $value = null): self
{
if (is_callable($field)) {
if ($this->isCallback($field)) {
return $this->whereNested($field, 'AND');
}

Expand Down Expand Up @@ -259,7 +259,7 @@ public function where($field, $operator = null, $value = null): self
*/
public function orWhere($field, $operator = null, $value = null): self
{
if (is_callable($field)) {
if ($this->isCallback($field)) {
return $this->whereNested($field, 'OR');
}

Expand Down Expand Up @@ -744,7 +744,7 @@ private function escapeValue($value): string
*/
public function if($value, callable $callback, ?callable $default = null): self
{
$payload = is_callable($value) ? $value() : $value;
$payload = $this->isCallback($value) ? $value() : $value;

if ($payload === true || $this->hasValue($payload)) {
$callback($this);
Expand Down Expand Up @@ -1363,7 +1363,7 @@ public function load($relations, ?callable $callback = null): self

foreach ($relations as $key => $value) {
$relation = is_string($key) ? $key : $value;
$constraint = is_callable($value) ? $value : $callback;
$constraint = $this->isCallback($value) ? $value : $callback;

if (str_contains($relation, '.')) {
$this->loadNestedRelations($models, $relation, $constraint);
Expand Down Expand Up @@ -1654,7 +1654,7 @@ public function embedCount($relations, ?callable $callback = null): self

if (is_array($relations)) {
foreach ($relations as $key => $value) {
if (is_callable($value)) {
if ($this->isCallback($value)) {
$this->eagerLoad["count:{$key}"] = $value;
} else {
$this->eagerLoad["count:{$value}"] = null;
Expand Down Expand Up @@ -1953,7 +1953,7 @@ public function embed($relations, ?callable $callback = null): self

if (is_array($relations)) {
foreach ($relations as $key => $value) {
if (is_callable($value)) {
if ($this->isCallback($value)) {
// Format: ['comments' => function($q) { ... }]
[$relationName, $columns] = $this->parseRelationWithColumns($key);
$constraint = !empty($columns)
Expand Down
6 changes: 3 additions & 3 deletions src/Phaseolies/Database/Entity/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public function select(array|string ...$fields): self
*/
public function where($field, $operator = null, $value = null): self
{
if (is_callable($field)) {
if ($this->isCallback($field)) {
return $this->whereNested($field, 'AND');
}

Expand Down Expand Up @@ -157,7 +157,7 @@ public function where($field, $operator = null, $value = null): self
*/
public function orWhere($field, $operator = null, $value = null): self
{
if (is_callable($field)) {
if ($this->isCallback($field)) {
return $this->whereNested($field, 'OR');
}

Expand Down Expand Up @@ -359,7 +359,7 @@ protected function getTableColumns(?string $table = null): array
*/
public function if($value, callable $callback, ?callable $default = null): self
{
$payload = is_callable($value) ? $value() : $value;
$payload = $this->isCallback($value) ? $value() : $value;

if ($payload === true || $this->hasValue($payload)) {
$callback($this);
Expand Down
14 changes: 14 additions & 0 deletions src/Phaseolies/Database/Entity/Query/Grammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ protected function assertValidIdentifier(string $identifier): void
}
}

/**
* Determine if a value is a callback (closure, invokable or array callable).
*
* Strings are never treated as callbacks: names like "date" or "time"
* pass is_callable() but are column names or values here.
*
* @param mixed $value
* @return bool
*/
protected function isCallback(mixed $value): bool
{
return !is_string($value) && is_callable($value);
}

/**
* Get the current driver
*
Expand Down
115 changes: 115 additions & 0 deletions tests/Builder/StringArgumentNotCallableTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

namespace Tests\Unit\Builder;

use PDO;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use Phaseolies\Database\Entity\Builder;
use Phaseolies\Database\Entity\Query\Builder as QueryBuilder;

/**
* Strings such as "date" or "time" pass is_callable() because they are PHP
* function names. The builders must treat them as column names / values,
* never invoke them as closures.
*/
#[AllowMockObjectsWithoutExpectations]
class StringArgumentNotCallableTest extends TestCase
{
private function entityBuilder(): Builder
{
$pdo = $this->createMock(PDO::class);
$pdo->method('getAttribute')->willReturn('mysql');

return new Builder($pdo, 'users', StringArgumentModelStub::class, 15);
}

private function queryBuilder(): QueryBuilder
{
$pdo = $this->createMock(PDO::class);
$pdo->method('getAttribute')->willReturn('mysql');

return new QueryBuilder($pdo, 'users');
}

public static function functionNamedColumns(): array
{
return [['date'], ['time'], ['key'], ['max'], ['min'], ['file'], ['end'], ['range']];
}

#[DataProvider('functionNamedColumns')]
public function testWhereOnColumnNamedLikePhpFunctionIsACondition(string $column): void
{
$sql = $this->entityBuilder()->where($column, 'x')->orWhere($column, '!=', 'y')->toSql();

$this->assertStringContainsString("{$column} = ?", $sql);
$this->assertStringContainsString("OR {$column} != ?", $sql);
}

#[DataProvider('functionNamedColumns')]
public function testQueryBuilderWhereOnColumnNamedLikePhpFunctionIsACondition(string $column): void
{
$sql = $this->queryBuilder()->where($column, 'x')->orWhere($column, 'y')->toSql();

$this->assertStringContainsString("{$column} = ?", $sql);
$this->assertStringContainsString("OR {$column} = ?", $sql);
}

public function testWhereStillAcceptsClosuresForNesting(): void
{
$sql = $this->entityBuilder()
->where(fn($q) => $q->where('a', 1)->orWhere('b', 2))
->toSql();

$this->assertStringContainsString('(a = ? OR b = ?)', $sql);
}

public function testWhereStillAcceptsInvokableObjectsForNesting(): void
{
$nested = new class {
public function __invoke($q): void
{
$q->where('a', 1);
}
};

$this->assertStringContainsString('(a = ?)', $this->queryBuilder()->where($nested)->toSql());
}

public function testIfDoesNotInvokeStringValuesAsFunctions(): void
{
$called = 0;
$callback = function () use (&$called) {
$called++;
};

// "phpinfo" would print the server configuration if invoked.
ob_start();
$this->entityBuilder()->if('phpinfo', $callback);
$this->queryBuilder()->if('phpinfo', $callback);
$output = ob_get_clean();

$this->assertSame('', $output);
$this->assertSame(2, $called);
}

public function testIfStillEvaluatesClosureConditions(): void
{
$called = false;

$this->entityBuilder()->if(fn() => false, function () use (&$called) {
$called = true;
});

$this->assertFalse($called);
}
}

class StringArgumentModelStub
{
public function usesTimestamps(): bool
{
return false;
}
}
Loading