'PHP, Static Analysis, and Recursive Type Checking

I'm looking at a Database ORM that uses an array to define the WHERE clause, e.g.

$articles->find('all', [
    'OR' => [
      'category_id IS NULL',
      'category_id' => $id,
    ],
  ]);

The "array keys" become part of the SQL, so they must be a developer defined string (aka a literal-string), otherwise you can have mistakes like this:

$articles->find('all', [
    'OR' => [
      'category_id IS NULL',
      'category_id = ' . $id, // INSECURE, SQLi
    ],
  ]);

If the "array values" simply contained the values to be parameterised (i.e. user values), then I could specify the parameter type as array<literal-string, int|string>.

But, as you will notice with the 'OR' key, the parameter can contain nested arrays, and can be many levels deep.

Is it possible to get Static Analysis tools like Psalm or PHPStan to work with this?


I can use the CakePHP implementation as an example of how this works:

<?php

  class orm {

    /**
     * @param array<int, literal-string|array<mixed>>|array<literal-string, int|string|array<mixed>> $conditions
     */
    public function find(string $finder, array $conditions): void {

      print_r($this->_addConditions($conditions));

    }

    /**
     * @param array<int, literal-string|array<mixed>>|array<literal-string, int|string|array<mixed>> $conditions
     * @param literal-string $conjunction
     * @return array{literal-string, array<int, mixed>}
     */
    private function _addConditions(array $conditions, string $conjunction = 'AND'): array {

      // https://github.com/cakephp/cakephp/blob/ab052da10dc5ceb2444c29aef838d10844fe5995/src/Database/Expression/QueryExpression.php#L654

      $operators = ['and', 'or', 'xor'];

      $sql = [];
      $parameters = [];

      foreach ($conditions as $k => $c) {

        if (is_numeric($k)) {

          if (is_array($c)) {
            /** @var array<int, array<mixed>> $sub_conditions */
            $sub_conditions = $c;
            list($new_sql, $new_parameters) = $this->_addConditions($sub_conditions, 'AND');
            $sql[] = $new_sql;
            $parameters = array_merge($parameters, $new_parameters);
          } else if (is_string($c)) {
            $sql[] = $c; // $c must be a literal-string
          }

        } else {

          $operatorId = array_search(strtolower($k), $operators);
          if ($operatorId !== false) {
            /** @var array<literal-string, int|string|array<mixed>> $sub_conditions */
            $sub_conditions = $c;
            list($new_sql, $new_parameters) = $this->_addConditions($sub_conditions, $operators[$operatorId]);
            $sql[] = $new_sql;
            $parameters = array_merge($parameters, $new_parameters);
          } else {
            $sql[] = $k . ' = ?'; // $k must be a literal-string
            $parameters[] = $c;
          }

        }

      }

      /** @var literal-string $sql */
      $sql = '(' . implode(' ' . $conjunction . ' ', $sql) . ')';

      return [$sql, $parameters];

    }

  }

  $articles = new orm();

?>


Solution 1:[1]

PHPStan

"Recursive types aren't supported now and I don't know if they ever will" Mar 2021.

"PHPStan doesn't support recursive types as they're very hard to do" May 2022.

Psalm

"Recursive types aren’t supported by Psalm (and probably won’t ever be tbh)" Jul 2019.

"Yeah, you can't - if you search issues you'll see a few requests for recursive types that have come up (and I've dismissed)" Feb 2020.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Craig Francis