To determine types involved in SQL based apis phpstan-dba needs to know your sql queries:

use Doctrine\DBAL\Connection;

class Foo
{
    /** @return array{email: string, adaid: int} */
    public function fetchByEmail(Connection $conn, string $email)
    {
        $query = 'SELECT email, adaid FROM ada WHERE email = :email');
        // phpstan-dba can infer the array shape based on the database schema and the sql query
        // => array{email: string, adaid: int}
        return $conn->fetchOne($query, ['email' => $email]); 
    }
}

This even works for conditional queries, as long as the query strings can be figured out at analysis time:

use Doctrine\DBAL\Connection;

class Foo2
{
    /** @return array{email: string, adaid: int} */
    public function fetchByEmail(Connection $conn, string $email, bool $onlyActive, int $typeId)
    {
        $query = 'SELECT email, adaid FROM ada WHERE email = :email');
        $params = ['email' => $email];
        
        if ($onlyActive) {
            $query .= ' AND active = 1';
        }
        
        if ($typeId > 0) {
            $query .= ' AND type_id = :typeId';
            $params['typeId'] = $typeId;
        }
        
        // phpstan-dba can infer the array shape based on the database schema and the sql query
        // because all possible combinations of queries strings are known at analysis time.
        // => array{email: string, adaid: int}
        return $conn->fetchOne($query, $params); 
    }
}

The golden phpstan-dba rules

As a rule of thumb:

  • separate user input from your sql query (use prepared statements)
  • make sure the sql query used is built from scalar values, but does not contain regular string
  • when string is involved try to use literal-string and numeric-string
  • if there is no way around string, check whether @phpstandba-inference-placeholder can help you out (see below)

If you stick to these rules phpstan-dba can figure out the query and therefore provide the following features:

Handle more dynamic queries: meet @phpstandba-inference-placeholder

In rare cases SQL queries can get pretty complex or depend on external configuration, which cannot be built without using a string type:

use Doctrine\DBAL\Connection;

class Foo3
{
    /** @return array{email: string, adaid: int} */
    public function fetchByType(Connection $conn, string $typeId, array $filters)
    {
        $query = 'SELECT email, adaid FROM ada WHERE type_id = :typeId');
        $params = ['typeId' => $typeId];
        
        // because of concat a `string` into the SQL query, phpstan-dba can't know the SQL query at analysis time.
        // => the return type cannot be inferred, sql validation cannot happen.
        $query .= $this->builtFilters($filters)
        return $conn->fetchOne($query, $params); 

    }
    
    /**
     * Builts common filter logic meant for re-use 
     */
    private function builtFilters(array $filter):string {
        $conditions = [];
        
        if (array_key_exists('active', $filter)) {
            $conditions[] = 'active = 1';
        }
        
        if (array_key_exists('deleted', $filter)) {
            $conditions[] = 'deleted = 1';
        }
        
        return implode(' AND ', $conditions);
    }
}

Since version 0.2.42 you can use @phpstandba-inference-placeholder to give phpstan-dba a hint about the query involved, so type inference can still be done:

use Doctrine\DBAL\Connection;

class Foo4
{
    /** @return array{email: string, adaid: int} */
    public function fetchByType(Connection $conn, string $typeId, array $filters)
    {
        $query = 'SELECT email, adaid FROM ada WHERE type_id = :typeId');
        $params = ['typeId' => $typeId];
        
        // the SQL query will be inferred as 'SELECT email, adaid FROM ada WHERE type_id = :typeId AND 1=1',
        // because of the used `@phpstandba-inference-placeholder`.
        // => array{email: string, adaid: int}
        $query .= $this->builtFilters($filters)
        return $conn->fetchOne($query, $params); 

    }
    
    /**
     * Builts common filter logic meant for re-use
     * 
     * @phpstandba-inference-placeholder ' AND 1=1'
     */
    private function builtFilters(array $filter):string {
        $conditions = [];
        
        if (array_key_exists('active', $filter)) {
            $conditions[] = 'active = 1';
        }
        
        if (array_key_exists('deleted', $filter)) {
            $conditions[] = 'deleted = 1';
        }
        
        return implode(' AND ', $conditions);
    }
}

At analysis time the SQL expression given in @phpstandba-inference-placeholder will be picked up and used in the analysis. Since the resulting SQL query is executable and can be EXPLAIN‘ed, phpstan-dba can do its magic.

As things stand right now, using @phpstandba-inference-placeholder is limited to same-class private method calls.

Found a bug? Please help improve this article.


<
Previous Post
rexstan meets phpstan-dba
>
Next Post
Placeholder validation in sql queries