Skip to content

Commit

Permalink
Major changes to fix wrap() bug
Browse files Browse the repository at this point in the history
  • Loading branch information
n0nag0n committed Jan 31, 2025
1 parent f6a8e34 commit 37d2989
Show file tree
Hide file tree
Showing 6 changed files with 318 additions and 58 deletions.
141 changes: 94 additions & 47 deletions src/ActiveRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,14 @@ abstract class ActiveRecord extends Base implements JsonSerializable
public const HAS_ONE = 'has_one';

/**
* @var array Stored the SQL Expressions of the SQL.
* @var array Store the SQL expressions inside wrapped (parentheses).
*/
protected array $expressions = [];
protected array $wrapExpressions = [];

/**
* @var boolean if a statement is being wrapped in parentheses
*/
protected bool $wrap = false;

/**
* @var array Stored the Expressions of the SQL.
Expand All @@ -83,7 +88,7 @@ abstract class ActiveRecord extends Base implements JsonSerializable
/**
* @var string SQL that is built to be used by execute()
*/
protected string $built_sql = '';
protected string $builtSql = '';

/**
* Captures all the joins that are made
Expand Down Expand Up @@ -196,6 +201,9 @@ public function __call($name, $args)
$operator = ActiveRecordData::OPERATORS[$name];
$value = isset($args[1]) ? $args[1] : null;
$last_arg = end($args);

// If the last arg is "OR" make this an OR condition
// e.g. $this->eq('name', 'John', 'or')->eq('age', 25);
$and_or_or = is_string($last_arg) && strtolower($last_arg) === 'or' ? 'OR' : 'AND';

$this->addCondition($field, $operator, $value, $and_or_or);
Expand Down Expand Up @@ -648,39 +656,47 @@ protected function &getRelation(string $name)
}
/**
* helper function to build SQL with sql parts.
* @param string $sql_statement The SQL part will be build.
* @param string $sqlStatement The SQL part will be build.
* @param ActiveRecord $o The reference to $this
* @return string
*/
protected function buildSqlCallback(string $sql_statement, ActiveRecord $object): string
protected function buildSqlCallback(string $sqlStatement, ActiveRecord $object): string
{
if ('select' === $sql_statement && null == $object->$sql_statement) {
$sql_statement = strtoupper($sql_statement) . ' ' . $this->escapeIdentifier($object->table) . '.*';
} elseif (('update' === $sql_statement || 'from' === $sql_statement) && null == $object->$sql_statement) {
$sql_statement = strtoupper($sql_statement) . ' ' . $this->escapeIdentifier($object->table);
} elseif ('delete' === $sql_statement) {
$sql_statement = strtoupper($sql_statement) . ' ';
// First add the SELECT table.*
if ('select' === $sqlStatement && null == $object->$sqlStatement) {
$sqlStatement = strtoupper($sqlStatement) . ' ' . $this->escapeIdentifier($object->table) . '.*';
} elseif (('update' === $sqlStatement || 'from' === $sqlStatement) && null == $object->$sqlStatement) {
$sqlStatement = strtoupper($sqlStatement) . ' ' . $this->escapeIdentifier($object->table);
} elseif ('delete' === $sqlStatement) {
$sqlStatement = strtoupper($sqlStatement);
} else {
$sql_statement = (null !== $object->$sql_statement) ? $object->$sql_statement . ' ' : '';
$sqlStatement = (null !== $object->$sqlStatement) ? (string) $object->$sqlStatement : '';
}

return $sql_statement;
return $sqlStatement;
}

/**
* helper function to build SQL with sql parts.
* @param array $sql_statements The SQL part will be build.
* @param array $sqlStatements The SQL part will be build.
* @return string
*/
protected function buildSql(array $sql_statements = []): string
protected function buildSql(array $sqlStatements = []): string
{
foreach ($sql_statements as &$sql) {
$sql = $this->buildSqlCallback($sql, $this);
$finalSql = [];
foreach ($sqlStatements as $sql) {
$statement = $this->buildSqlCallback($sql, $this);
if ($statement !== '') {
$finalSql[] = $statement;
}
}
//this code to debug info.
//echo 'SQL: ', implode(' ', $sql_statements), "\n", "PARAMS: ", implode(', ', $this->params), "\n";
$this->built_sql = implode(' ', $sql_statements);
return $this->built_sql;
//echo 'SQL: ', implode(' ', $sqlStatements), "\n", "PARAMS: ", implode(', ', $this->params), "\n";
$this->builtSql = implode(' ', $finalSql);

// get rid of multiple spaces in the query for prettiness
$this->builtSql = preg_replace('/\s+/', ' ', $this->builtSql);
return $this->builtSql;
}

/**
Expand All @@ -690,30 +706,46 @@ protected function buildSql(array $sql_statements = []): string
*/
public function getBuiltSql(): string
{
return $this->built_sql;
return $this->builtSql;
}

public function startWrap(): self
{
$this->wrap = true;
return $this;
}

/**
* Alias to encWrap
*
* @deprecated 0.6.0
* @param string|null $op If give this param will build one WrapExpressions include the stored expressions add into WHERE. Otherwise will stored the expressions into array.
* @return self
*/
public function wrap(?string $op = null): self
{
return $op === null ? $this->startWrap() : $this->endWrap($op);
}

/**
* make wrap when build the SQL expressions of WHERE.
* @param string $op If give this param will build one WrapExpressions include the stored expressions add into WHERE. Otherwise will stored the expressions into array.
* @return ActiveRecord
*/
public function wrap(?string $op = null)
public function endWrap(string $op): self
{
if ($op !== null) {
$this->wrap = false;
if (is_array($this->expressions) && count($this->expressions) > 0) {
$this->addConditionGroup(new WrapExpressions([
'delimiter' => ' ',
'target' => $this->expressions
]), 'or' === strtolower($op) ? 'OR' : 'AND');
}
$this->expressions = [];
} else {
$this->wrap = true;
$this->wrap = false;
if (is_array($this->wrapExpressions) === true && count($this->wrapExpressions) > 0) {
$this->addCondition(new WrapExpressions([
'delimiter' => ('or' === strtolower($op) ? ' OR ' : ' AND '),
'target' => $this->wrapExpressions
]), null, null);
}
$this->wrapExpressions = [];
return $this;
}


/**
* helper function to build place holder when making SQL expressions.
* @param mixed $value the value will bind to SQL, just store it in $this->params.
Expand All @@ -738,25 +770,31 @@ protected function filterParam($value)
/**
* helper function to add condition into WHERE.
* create the SQL Expressions.
* @param string $field The field name, the source of Expressions
* @param string $operator
* @param string|Expressions $field The field name, the source of Expressions
* @param ?string $operator
* @param mixed $value the target of the Expressions
* @param string $delimiter the operator to concat this Expressions into WHERE or SET statement.
* @param string $name The Expression will contact to.
*/
public function addCondition(string $field, string $operator, $value, string $delimiter = 'AND', string $name = 'where')
public function addCondition($field, ?string $operator, $value, string $delimiter = 'AND', string $name = 'where')
{
// This will catch unique conditions such as IS NULL, IS NOT NULL, etc
// You only need to filter by a param if there's a param to really filter by
if (stripos($operator, 'NULL') === false) {
// A true null value is passed in from a endWrap() method to skip the param.
if ($operator !== null && stripos((string) $operator, 'NULL') === false) {
$value = $this->filterParam($value);
}

// This is used for wrapped expressions so extra statements aren't printed out.
if ($operator === null) {
$operator = '';
}
$name = strtolower($name);

// skip adding the `table.` prefix if it's already there or a function is being supplied.
$skip_table_prefix = (strpos($field, '.') !== false || strpos($field, '(') !== false);
$skipTablePrefix = $field instanceof WrapExpressions || is_string($field) === true && (strpos($field, '.') !== false || strpos($field, '(') !== false);
$expressions = new Expressions([
'source' => ('where' === $name && $skip_table_prefix === false ? $this->escapeIdentifier($this->table) . '.' : '') . $this->escapeIdentifier($field),
'source' => ('where' === $name && $skipTablePrefix === false ? $this->escapeIdentifier($this->table) . '.' : '') . (is_string($field) === true ? $this->escapeIdentifier($field) : $field),
'operator' => $operator,
'target' => (
is_array($value)
Expand All @@ -769,10 +807,11 @@ public function addCondition(string $field, string $operator, $value, string $de
)
]);
if ($expressions) {
if (!$this->wrap) {
if ($this->wrap === false) {
$this->addConditionGroup($expressions, $delimiter, $name);
} else {
$this->addExpression($expressions, $delimiter);
// This method is only for wrapping conditions in parentheses
$this->addExpression($expressions);
}
}
}
Expand Down Expand Up @@ -805,12 +844,13 @@ public function join(string $table, string $on, string $type = 'LEFT')
* @param Expressions $exp The expression will be stored.
* @param string $delimiter The operator to concat this Expressions into WHERE statement.
*/
protected function addExpression(Expressions $expressions, string $delimiter)
protected function addExpression(Expressions $expressions)
{
if (!is_array($this->expressions) || count($this->expressions) == 0) {
$this->expressions = [ $expressions ];
$wrapExpressions =& $this->wrapExpressions;
if (is_array($wrapExpressions) === false || count($wrapExpressions) === 0) {
$wrapExpressions = [ $expressions ];
} else {
$this->expressions[] = new Expressions(['operator' => $delimiter, 'target' => $expressions]);
$wrapExpressions[] = new Expressions([ 'operator' => '', 'target' => $expressions ]);
}
}

Expand All @@ -823,9 +863,16 @@ protected function addExpression(Expressions $expressions, string $delimiter)
protected function addConditionGroup(Expressions $expressions, string $operator, string $name = 'where')
{
if (!$this->{$name}) {
$this->{$name} = new Expressions(['operator' => strtoupper($name) , 'target' => $expressions]);
$this->{$name} = new Expressions([
'operator' => strtoupper($name),
'target' => $expressions
]);
} else {
$this->{$name}->target = new Expressions(['source' => $this->$name->target, 'operator' => $operator, 'target' => $expressions]);
$this->{$name}->target = new Expressions([
'source' => $this->$name->target,
'operator' => $operator,
'target' => $expressions
]);
}
}

Expand Down
7 changes: 6 additions & 1 deletion src/WrapExpressions.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ class WrapExpressions extends Expressions

public function __toString()
{
return $this->start . implode(($this->delimiter ? $this->delimiter : ','), $this->target) . $this->end;
if (is_array($this->target) === true) {
$this->target = array_map(function ($target) {
return $target instanceof Expressions ? $target->__toString() : $target;
}, $this->target);
}
return $this->start . implode($this->delimiter, $this->target) . $this->end;
}
}
43 changes: 38 additions & 5 deletions tests/ActiveRecordPdoIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public function testEdit()
$this->assertEquals($original_id, $user->id);

$sql = $user->getBuiltSql();
$this->assertStringContainsString('UPDATE "user" SET "name" = :ph3 , "password" = :ph4 WHERE "user"."id" = :ph5', $sql);
$this->assertStringContainsString('UPDATE "user" SET "name" = :ph3 , "password" = :ph4 WHERE "user"."id" = :ph5', $sql);
}

public function testUpdateNoChanges()
Expand Down Expand Up @@ -235,7 +235,7 @@ public function getDirty()

$user->isNotNull('id')->eq('id', 1)->lt('id', 2)->gt('id', 0)->find();
$sql = $user->getBuiltSql();
$this->assertStringContainsString('SELECT "user".* FROM "user" WHERE "user"."id" IS NOT NULL AND "user"."id" = 1 AND "user"."id" < 2 AND "user"."id" > 0', $sql);
$this->assertStringContainsString('SELECT "user".* FROM "user" WHERE "user"."id" IS NOT NULL AND "user"."id" = 1 AND "user"."id" < 2 AND "user"."id" > 0', $sql);
$this->assertGreaterThan(0, $user->id);
$this->assertSame([], $user->getDirty());
$user->name = 'testname';
Expand All @@ -245,7 +245,7 @@ public function getDirty()
unset($user->name);
$this->assertSame([], $user->getDirty());
$user->isNotNull('id')->eq('id', 'aaa"')->wrap()->lt('id', 2)->gt('id', 0)->wrap('OR')->find();
$this->assertGreaterThan(0, $user->id);
$this->assertEmpty($user->id);
$user->isNotNull('id')->between('id', [0, 2])->find();
$this->assertGreaterThan(0, $user->id);
}
Expand All @@ -260,7 +260,7 @@ public function testWrapWithArrays()
$user->password = md5('demo1');
$user->insert();

$users = $user->isNotNull('id')->wrap('OR')->in('name', [ 'demo', 'demo1' ])->wrap('AND')->lt('id', 3)->gt('id', 0)->wrap('OR')->findAll();
$users = $user->isNotNull('id')->wrap()->in('name', [ 'demo', 'demo1' ])->wrap('OR')->lt('id', 3)->gt('id', 0)->findAll();
$this->assertGreaterThan(0, $users[0]->id);
$this->assertGreaterThan(0, $users[1]->id);
}
Expand Down Expand Up @@ -293,7 +293,7 @@ public function testDelete()
$this->assertEmpty($new_contact->id);
$this->assertInstanceOf(User::class, $new_user->find($uid));
$this->assertEmpty($new_user->id);
$this->assertStringContainsString('DELETE FROM "contact" WHERE "contact"."id" = :ph4', $sql);
$this->assertStringContainsString('DELETE FROM "contact" WHERE "contact"."id" = :ph4', $sql);
}

public function testDeleteWithConditions()
Expand Down Expand Up @@ -671,4 +671,37 @@ public function testCallMethodPassingToPdoConnection()
$this->assertInstanceOf(PdoStatementAdapter::class, $result);
$this->assertNotInstanceOf(ActiveRecord::class, $result);
}

public function testWrapMultipleOrStatements()
{
$record = new User(new PDO('sqlite:test.db'));
$record
->notNull('name')
->startWrap()
->eq('name', 'John')
->eq('id', 1)
->gte('password', '123')
->endWrap('OR')
->eq('id', 2)
->find();
$sql = $record->getBuiltSql();
$this->assertEquals('SELECT "user".* FROM "user" WHERE "user"."name" IS NOT NULL AND ("user"."name" = :ph1 OR "user"."id" = 1 OR "user"."password" >= :ph2) AND "user"."id" = 2 LIMIT 1', $sql);
}

public function testWrapWithComplexLogic()
{
$record = new User(new PDO('sqlite:test.db'));
$record
->startWrap()
->eq('name', 'John')
->in('id', [ 1,5,9 ])
->eq('id', 1)
->endWrap('OR')
->notNull('name')
->between('id', [ 1, 2 ])
->join('contact', '"contact"."user_id" = "user"."id"')
->find();
$sql = $record->getBuiltSql();
$this->assertEquals('SELECT "user".* FROM "user" LEFT JOIN contact ON "contact"."user_id" = "user"."id" WHERE ("user"."name" = :ph1 OR "user"."id" IN (:ph2,:ph3,:ph4) OR "user"."id" = 1) AND "user"."name" IS NOT NULL AND "user"."id" BETWEEN :ph5 AND :ph6 LIMIT 1', $sql);
}
}
14 changes: 9 additions & 5 deletions tests/ActiveRecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@

class ActiveRecordTest extends \PHPUnit\Framework\TestCase
{
protected function createEmptyRecord()
{
return new class (null, 'test_table') extends ActiveRecord {
};
}

public function testMagicSet()
{
$pdo_mock = $this->createStub(PDO::class);
Expand Down Expand Up @@ -117,17 +123,15 @@ public function testIsDirty()

public function testCopyFrom()
{
$record = new class (null, 'test_table') extends ActiveRecord {
};
$record = $this->createEmptyRecord();
$record->copyFrom(['name' => 'John']);
$this->assertEquals('John', $record->name);
$this->assertEquals(['name' => 'John'], $record->getData());
}

public function testIsset()
{
$record = new class (null, 'test_table') extends ActiveRecord {
};
$record = $this->createEmptyRecord();
$record->name = 'John';
$this->assertTrue(isset($record->name));
$this->assertFalse(isset($record->email));
Expand All @@ -144,7 +148,7 @@ public function query(string $sql, array $param = [], ?ActiveRecord $obj = null,
$record->join('table1', 'table1.some_id = test_table.id');
$record->join('table2', 'table2.some_id = table1.id');
$result = $record->find()->getBuiltSql();
$this->assertEquals('SELECT test_table.* FROM test_table LEFT JOIN table1 ON table1.some_id = test_table.id LEFT JOIN table2 ON table2.some_id = table1.id LIMIT 1 ', $result);
$this->assertEquals('SELECT test_table.* FROM test_table LEFT JOIN table1 ON table1.some_id = test_table.id LEFT JOIN table2 ON table2.some_id = table1.id LIMIT 1', $result);
}

public function testEscapeIdentifierSqlSrv()
Expand Down
Loading

0 comments on commit 37d2989

Please sign in to comment.