class NamedPlaceholderConverter

A class to convert a SQL statement with named placeholders to positional.

The parsing logic and the implementation is inspired by the PHP PDO parser, and a simplified copy of the parser implementation done by the Doctrine DBAL project.

This class is a near-copy of Doctrine\DBAL\SQL\Parser, which is part of the Doctrine project: <http://www.doctrine-project.org&gt;. It was copied from version 4.0.0.

Original copyright:

Copyright (c) 2006-2018 Doctrine Project

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

@internal

Hierarchy

Expanded class hierarchy of NamedPlaceholderConverter

See also

https://github.com/doctrine/dbal/blob/4.0.0/src/SQL/Parser.php

1 file declares its use of NamedPlaceholderConverter
NamedPlaceholderConverterTest.php in core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php

File

core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php, line 38

Namespace

Drupal\mysqli\Driver\Database\mysqli
View source
final class NamedPlaceholderConverter {
  
  /**
   * A list of regex patterns for parsing.
   */
  private const SPECIAL_CHARS = ':\\?\'"`\\[\\-\\/';
  private const BACKTICK_IDENTIFIER = '`[^`]*`';
  private const BRACKET_IDENTIFIER = '(?<!\\b(?i:ARRAY))\\[(?:[^\\]])*\\]';
  private const MULTICHAR = ':{2,}';
  private const NAMED_PARAMETER = ':[a-zA-Z0-9_]+';
  private const POSITIONAL_PARAMETER = '(?<!\\?)\\?(?!\\?)';
  private const ONE_LINE_COMMENT = '--[^\\r\\n]*';
  private const MULTI_LINE_COMMENT = '/\\*([^*]+|\\*+[^/*])*\\**\\*/';
  private const SPECIAL = '[' . self::SPECIAL_CHARS . ']';
  private const OTHER = '[^' . self::SPECIAL_CHARS . ']+';
  
  /**
   * The combined regex pattern for parsing.
   */
  private string $sqlPattern;
  
  /**
   * The list of original named arguments.
   *
   * The initial placeholder colon is removed.
   *
   * @var array<string|int, mixed>
   */
  private array $originalParameters = [];
  
  /**
   * The maximum positional placeholder parsed.
   *
   * Normally Drupal does not produce SQL with positional placeholders, but
   * this is to manage the edge case.
   */
  private int $originalParameterIndex = 0;
  
  /**
   * The converted SQL statement in its parts.
   *
   * @var list<string>
   */
  private array $convertedSQL = [];
  
  /**
   * The list of converted arguments.
   *
   * @var list<mixed>
   */
  private array $convertedParameters = [];
  public function __construct() {
    // Builds the combined regex pattern for parsing.
    $this->sqlPattern = sprintf('(%s)', implode('|', [
      $this->getAnsiSQLStringLiteralPattern("'"),
      $this->getAnsiSQLStringLiteralPattern('"'),
      self::BACKTICK_IDENTIFIER,
      self::BRACKET_IDENTIFIER,
      self::MULTICHAR,
      self::ONE_LINE_COMMENT,
      self::MULTI_LINE_COMMENT,
      self::OTHER,
    ]));
  }
  
  /**
   * Parses an SQL statement with named placeholders.
   *
   * This method explodes the SQL statement in parts that can be reassembled
   * into a string with positional placeholders.
   *
   * @param string $sql
   *   The SQL statement with named placeholders.
   * @param array<string|int, mixed> $args
   *   The statement arguments.
   */
  public function parse(string $sql, array $args) : void {
    // Reset the object state.
    $this->originalParameters = [];
    $this->originalParameterIndex = 0;
    $this->convertedSQL = [];
    $this->convertedParameters = [];
    foreach ($args as $key => $value) {
      if (is_int($key)) {
        // Positional placeholder; edge case.
        $this->originalParameters[$key] = $value;
      }
      else {
        // Named placeholder like ':placeholder'; remove the initial colon.
        $parameter = $key[0] === ':' ? substr($key, 1) : $key;
        $this->originalParameters[$parameter] = $value;
      }
    }
    /** @var array<string,callable> $patterns */
    $patterns = [
      self::NAMED_PARAMETER => function (string $sql) : void {
        $this->addNamedParameter($sql);
      },
      self::POSITIONAL_PARAMETER => function (string $sql) : void {
        $this->addPositionalParameter($sql);
      },
      $this->sqlPattern => function (string $sql) : void {
        $this->addOther($sql);
      },
      self::SPECIAL => function (string $sql) : void {
        $this->addOther($sql);
      },
    ];
    $offset = 0;
    while (($handler = current($patterns)) !== FALSE) {
      if (preg_match('~\\G' . key($patterns) . '~s', $sql, $matches, 0, $offset) === 1) {
        $handler($matches[0]);
        reset($patterns);
        $offset += strlen($matches[0]);
      }
      elseif (preg_last_error() !== PREG_NO_ERROR) {
        throw new \RuntimeException('Regular expression error');
      }
      else {
        next($patterns);
      }
    }
    assert($offset === strlen($sql));
  }
  
  /**
   * Helper to return a regex pattern from a delimiter character.
   *
   * @param string $delimiter
   *   A delimiter character.
   *
   * @return string
   *   The regex pattern.
   */
  private function getAnsiSQLStringLiteralPattern(string $delimiter) : string {
    return $delimiter . '[^' . $delimiter . ']*' . $delimiter;
  }
  
  /**
   * Adds a positional placeholder to the converted parts.
   *
   * Normally Drupal does not produce SQL with positional placeholders, but
   * this is to manage the edge case.
   *
   * @param string $sql
   *   The SQL part.
   */
  private function addPositionalParameter(string $sql) : void {
    $index = $this->originalParameterIndex;
    if (!array_key_exists($index, $this->originalParameters)) {
      throw new \RuntimeException('Missing Positional Parameter ' . $index);
    }
    $this->convertedSQL[] = '?';
    $this->convertedParameters[] = $this->originalParameters[$index];
    $this->originalParameterIndex++;
  }
  
  /**
   * Adds a named placeholder to the converted parts.
   *
   * @param string $sql
   *   The SQL part.
   */
  private function addNamedParameter(string $sql) : void {
    $name = substr($sql, 1);
    if (!array_key_exists($name, $this->originalParameters)) {
      throw new \RuntimeException('Missing Named Parameter ' . $name);
    }
    $this->convertedSQL[] = '?';
    $this->convertedParameters[] = $this->originalParameters[$name];
  }
  
  /**
   * Adds a generic SQL string fragment to the converted parts.
   *
   * @param string $sql
   *   The SQL part.
   */
  private function addOther(string $sql) : void {
    $this->convertedSQL[] = $sql;
  }
  
  /**
   * Returns the converted SQL statement with positional placeholders.
   *
   * @return string
   *   The converted SQL statement with positional placeholders.
   */
  public function getConvertedSQL() : string {
    return implode('', $this->convertedSQL);
  }
  
  /**
   * Returns the array of arguments for use with positional placeholders.
   *
   * @return list<mixed>
   *   The array of arguments for use with positional placeholders.
   */
  public function getConvertedParameters() : array {
    return $this->convertedParameters;
  }

}

Members

Title Sort descending Modifiers Object type Summary
NamedPlaceholderConverter::$convertedParameters private property The list of converted arguments.
NamedPlaceholderConverter::$convertedSQL private property The converted SQL statement in its parts.
NamedPlaceholderConverter::$originalParameterIndex private property The maximum positional placeholder parsed.
NamedPlaceholderConverter::$originalParameters private property The list of original named arguments.
NamedPlaceholderConverter::$sqlPattern private property The combined regex pattern for parsing.
NamedPlaceholderConverter::addNamedParameter private function Adds a named placeholder to the converted parts.
NamedPlaceholderConverter::addOther private function Adds a generic SQL string fragment to the converted parts.
NamedPlaceholderConverter::addPositionalParameter private function Adds a positional placeholder to the converted parts.
NamedPlaceholderConverter::BACKTICK_IDENTIFIER private constant
NamedPlaceholderConverter::BRACKET_IDENTIFIER private constant
NamedPlaceholderConverter::getAnsiSQLStringLiteralPattern private function Helper to return a regex pattern from a delimiter character.
NamedPlaceholderConverter::getConvertedParameters public function Returns the array of arguments for use with positional placeholders.
NamedPlaceholderConverter::getConvertedSQL public function Returns the converted SQL statement with positional placeholders.
NamedPlaceholderConverter::MULTICHAR private constant
NamedPlaceholderConverter::MULTI_LINE_COMMENT private constant
NamedPlaceholderConverter::NAMED_PARAMETER private constant
NamedPlaceholderConverter::ONE_LINE_COMMENT private constant
NamedPlaceholderConverter::OTHER private constant
NamedPlaceholderConverter::parse public function Parses an SQL statement with named placeholders.
NamedPlaceholderConverter::POSITIONAL_PARAMETER private constant
NamedPlaceholderConverter::SPECIAL private constant
NamedPlaceholderConverter::SPECIAL_CHARS private constant A list of regex patterns for parsing.
NamedPlaceholderConverter::__construct public function

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.