-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Determine if binds are simple or named by looking at the $binds array #5138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
4ef0cd9
to
eb05138
Compare
Coding Standard checking was failed. |
On a related note, is the test To trigger it, the user should extend the Query class and assign the bindMarker property an empty value… doesn't seem very likely to happen. |
e474e6a
to
4aa7109
Compare
The PR currently makes an unit test fail:
See for instance at: https://github.com/codeigniter4/CodeIgniter4/pull/5138/checks?check_run_id=3712623836 I've already spend a lot of time investigating this. It seems to be due to some stale binds data that should be cleaned between query compilations. |
system/Database/Query.php
Outdated
} | ||
|
||
return strtr($sql, $replacers); | ||
return str_replace($replacersFrom, $replacersTo, $sql); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you replace strtr()
with str_replace()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Per my commit diff:
strtr() is purposed for single-character replacements, and less performant when used otherwise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed a (well-known) issue with str_replace()
: the replacements may overlap, so if a binded value contains itself a bind marker (e.g. ['comment' => 'a bind marker looks like :foobar:', 'foobar' => 'bazqux']
, that look-alike marker would be erroneously replaced.
Thus, I will revert to strtr()
, and we may want to add an unit test so that such regression wouldn't be introduced later.
some refs: answer on Stack Overflow, comment on php.net
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR adding an unit test for that case: #5184.
This passed that test. --- a/system/Database/Query.php
+++ b/system/Database/Query.php
@@ -11,6 +11,8 @@
namespace CodeIgniter\Database;
+use Closure;
+
/**
* Query builder
*/
@@ -301,6 +303,8 @@ class Query implements QueryInterface
/**
* Match bindings
+ *
+ * @param array<string, array{0: int|string|Closure, 1: bool}> $binds
*/
protected function matchNamedBinds(string $sql, array $binds): string
{
@@ -308,6 +312,11 @@ class Query implements QueryInterface
$replacersTo = [];
foreach ($binds as $placeholder => $value) {
+ // If $value[0] is Closure, it is a sub query. We don't need to replace.
+ if ($value[0] instanceof Closure) {
+ continue;
+ }
+
// $value[1] contains the boolean whether should be escaped or not
$escapedValue = $value[1] ? $this->db->escape($value[0]) : $value[0];
|
Indeed. I have pinpointed the deep cause of this, and have to work on a fix. Basically, we really shouldn't have a closure here. The current code works because it doesn't look at the binds array but at the SQL string, which doesn't have :placeholders: at that point, so it returns early and doesn't go to the closure. |
Thanks. Along the fix for the root cause, maybe such guard should also be added to prevent errors coming from other places. |
a5897b0
to
e77de3e
Compare
#5247 fixes the underlying cause of the error. Also, I don't think we should add the "skip if closure" guard, as a closure is really not expected here. If ever a closure lands here, it's for sure because of a mistake beforehand, that has to be fixed rather than ignored.
foreach ($binds as $placeholder => $value) {
if ($value instanceof Closure) {
$value = '(' . str_replace("\n", ' ', $value($builder)->getCompiledSelect()) . ')';
}
str_replace(":{$placeholder}:", $value, $sql);
} … but unless there is a demonstrated need for such feature, we should better leave it apart. |
On a related note, in this call: $this->matchSimpleBinds($sql, $binds, $bindCount, $ml);
protected function matchSimpleBinds(string $sql, array $binds, int $bindCount, int $ml): string It's redundant to pass However, it would be a BC break to change the method signature… |
Now that the prerequisite #5247 has been merged, I think this PR should be ready to be merged. |
@vlakoff This branch is too old. |
Rebased, although there were no merge conflicts. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
This is cleaner.
This conditional to determine if the array is a list or an associative map may be improved: if (is_numeric(key(array_slice($binds, 0, 1)))) { ... } Rather than if (is_int(key(array_slice($binds, 0, 1)))) { ... } |
91e28ab
to
52d3a32
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like a good set of changes that simplifies and should have a slight performance boost getting rid of the regex. Nicely done.
I just have one question about array_slice
. I realize that was in the original code, but looking at it in context now, I don't think it's necessary, but would love a second opinion on that.
system/Database/Query.php
Outdated
$binds = $this->binds; | ||
$binds = is_array($this->binds) ? $this->binds : [$this->binds]; | ||
|
||
if (is_int(key(array_slice($binds, 0, 1)))) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is array_slice
necessary here? Since the array has just been created, the current index should be the first element, so I think just doing is_int(key($binds))
is enough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or using array_key_first
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
$binds
may be a reference to $this->binds
, and as PHP doesn't copy arrays until they are modified, the pointer position of $binds
will be the same as of $this->binds
, which may have been modified beforehand, we can't know.
A benefit of key(array_slice($binds, 0, 1))
over reset($binds)
is that with the latter PHP would internally make a copy of the whole array, while the former just creates a tiny array of one item.
Good idea about array_key_first()
. As we are targetting PHP 7.3+, that's probably what we want to use.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add some tests for this?
system/Database/Query.php
Outdated
$binds = $this->binds; | ||
$binds = is_array($this->binds) ? $this->binds : [$this->binds]; | ||
|
||
if (is_int(key(array_slice($binds, 0, 1)))) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or using array_key_first
?
system/Database/Query.php
Outdated
$bindCount = 1; | ||
} else { | ||
$binds = $this->binds; | ||
$binds = is_array($this->binds) ? $this->binds : [$this->binds]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
$binds = is_array($this->binds) ? $this->binds : [$this->binds]; | |
$binds = (array) $this->binds; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't think of any regressions with that change, so why not.
Unit test aren't really my strong point… If you can provide me tests that fail before and pass after this PR, I would happily add them. |
I added tests: vlakoff@d7ad8ae.
|
* Simple binds: matchSimpleBinds() also handles the bind marker, and more elaborately: handles enclosing quotes, checks the number of markers… * Named binds: there just won't be replacements for matchNamedBinds() to make.
It's very unlikely that the bindMarker property would be empty.
Considering that $binds is usually an array, and that only matchSimpleBinds() needs the count.
We are looking for an integer, excluding other numeric values (decimals, etc). As a reminder: for array keys, PHP automatically casts strings representing integers to regular integers.
Same results, but simpler and faster code. Note array_key_first() is available on PHP 7.3+, which is thankfully the minimum PHP version required by CodeIgniter.
Force-pushed, squashing the unit tests and the code suggestions. I don't think we could improve the PR even further :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is good for me. Thanks for the tests!
I just thought of a possible regression with this change: - $binds = is_array($this->binds) ? $this->binds : [$this->binds];
+ $binds = (array) $this->binds; If the provided binds are not an array or a scalar, but a "stringable object" (for example Laravel's Str). Here is how it is casted:
Therefore, the following code would now fail: $value = Str::of('foobar');
$sql = 'SELECT * FROM posts WHERE title = ?';
$db->query($sql, $value); |
@vlakoff It is for all objects. By the way, is there any possiblity that |
I know it is for all objects, but passing a "stringable object" instead of a string seems to be a valid and possible use case.
So Anyway, |
And I think the code |
As discussed on GitHub PR codeigniter4#5138, $this->binds is not expected to be something else than an array.
Opened a PR for this: #5379. |
To determine if binds are simple or named, rather than trying to deduce it from the SQL string, which is the source of many errors (for example see #5114, and all the issues it links to), simply look at the
$binds
array. If it's an indexed array, the binds are simple, if it's an associative array, the binds are named.Note that if #5127 is merged before, this PR will need a slight rebasing before merging.