This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,45 @@
<?php
define( 'WP_MYSQL_ON_SQLITE_LOADER_PATH', __FILE__ );
/**
* Load the PDO MySQL-on-SQLite driver and its dependencies.
*/
require_once __DIR__ . '/php-polyfills.php';
require_once __DIR__ . '/version.php';
require_once __DIR__ . '/parser/class-wp-parser-grammar.php';
require_once __DIR__ . '/parser/class-wp-parser.php';
require_once __DIR__ . '/parser/class-wp-parser-node.php';
require_once __DIR__ . '/parser/class-wp-parser-token.php';
require_once __DIR__ . '/mysql/class-wp-mysql-token.php';
/*
* The MySQL lexer and parser have an optional native (e.g. Rust) implementation.
* When the native extension is loaded, it pre-declares WP_MySQL_Native_Lexer /
* WP_MySQL_Native_Parser; otherwise we fall back to the pure-PHP classes shipped
* here. WP_MySQL_Lexer / WP_MySQL_Parser is the public entrypoint either way.
*/
if ( class_exists( 'WP_MySQL_Native_Lexer', false ) ) {
require_once __DIR__ . '/mysql/native/class-wp-mysql-lexer.php';
} else {
require_once __DIR__ . '/mysql/class-wp-mysql-lexer.php';
}
if ( class_exists( 'WP_MySQL_Native_Parser', false ) ) {
require_once __DIR__ . '/mysql/native/mysql-rust-bridge.php';
require_once __DIR__ . '/mysql/native/class-wp-mysql-native-parser-node.php';
require_once __DIR__ . '/mysql/native/trait-wp-mysql-native-parser-impl.php';
require_once __DIR__ . '/mysql/native/class-wp-mysql-parser.php';
} else {
require_once __DIR__ . '/mysql/class-wp-mysql-parser.php';
}
require_once __DIR__ . '/sqlite/class-wp-sqlite-connection.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-configurator.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-driver.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-driver-exception.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-information-schema-builder.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-information-schema-exception.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-information-schema-reconstructor.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-pdo-user-defined-functions.php';
require_once __DIR__ . '/sqlite/class-wp-pdo-mysql-on-sqlite.php';
require_once __DIR__ . '/sqlite/class-wp-pdo-proxy-statement.php';
@@ -0,0 +1,63 @@
<?php
class WP_MySQL_Parser extends WP_Parser {
/**
* The current query AST.
*
* @var WP_Parser_Node|null
*/
private $current_ast;
/**
* Reset this parser with a new token stream.
*
* @param array<WP_Parser_Token> $tokens The parser tokens.
*/
public function reset_tokens( array $tokens ): void {
$this->tokens = $tokens;
$this->position = 0;
$this->current_ast = null;
}
/**
* Parse the next query from the input SQL string.
*
* This method reads tokens until a query is parsed, or the parsing fails.
* It returns a boolean indicating whether a query was successfully parsed.
*
* Example:
*
* // Parse all queries in the input SQL string.
* $parser = new WP_MySQL_Parser( $sql );
* while ( $parser->next_query() ) {
* $ast = $parser->get_query_ast();
* if ( ! $ast ) {
* // The parsing failed.
* }
* // The query was successfully parsed.
* }
*
* @return bool Whether a query was successfully parsed.
*/
public function next_query(): bool {
if ( $this->position >= count( $this->tokens ) ) {
return false;
}
$this->current_ast = $this->parse();
return true;
}
/**
* Get the current query AST.
*
* When no query has been parsed yet, the parsing failed, or the end of the
* input was reached, this method returns null.
*
* @see WP_MySQL_Parser::next_query() for usage example.
*
* @return WP_Parser_Node|null The current query AST, or null if no query was parsed.
*/
public function get_query_ast(): ?WP_Parser_Node {
return $this->current_ast;
}
}
@@ -0,0 +1,187 @@
<?php
/**
* MySQL token.
*
* This class represents a MySQL SQL token that is produced by WP_MySQL_Lexer,
* and consumed by WP_MySQL_Parser during the parsing process.
*/
class WP_MySQL_Token extends WP_Parser_Token {
/**
* Whether the NO_BACKSLASH_ESCAPES SQL mode is enabled.
*
* @var bool
*/
private $sql_mode_no_backslash_escapes_enabled;
/**
* Constructor.
*
* @param int $id Token type.
* @param int $start Byte offset in the input where the token begins.
* @param int $length Byte length of the token in the input.
* @param string $input Input bytes from which the token was parsed.
* @param bool $sql_mode_no_backslash_escapes_enabled Whether the NO_BACKSLASH_ESCAPES SQL mode is enabled.
*/
public function __construct(
int $id,
int $start,
int $length,
string $input,
bool $sql_mode_no_backslash_escapes_enabled
) {
$this->id = $id;
$this->start = $start;
$this->length = $length;
$this->input = $input;
$this->sql_mode_no_backslash_escapes_enabled = $sql_mode_no_backslash_escapes_enabled;
}
/**
* Get the name of the token.
*
* This method is intended to be used only for testing and debugging purposes,
* when tokens need to be presented by their names in a human-readable form.
* It should not be used in production code, as it's not performance-optimized.
*
* @return string The token name.
*/
public function get_name(): string {
$name = WP_MySQL_Lexer::get_token_name( $this->id );
if ( null === $name ) {
$name = 'UNKNOWN';
}
return $name;
}
/**
* Get the real unquoted value of the token.
*
* @return string The token value.
*/
public function get_value(): string {
$value = $this->get_bytes();
if (
WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $this->id
|| WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $this->id
|| WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $this->id
) {
// Remove bounding quotes.
$quote = $value[0];
$value = substr( $value, 1, -1 );
/*
* When the NO_BACKSLASH_ESCAPES SQL mode is enabled, we only need to
* handle escaped bounding quotes, as the other characters preserve
* their literal values.
*/
if ( $this->sql_mode_no_backslash_escapes_enabled ) {
return str_replace( $quote . $quote, $quote, $value );
}
/**
* Unescape MySQL escape sequences.
*
* MySQL string literals use backslash as an escape character, and
* the string bounding quotes can also be escaped by being doubled.
*
* The escaping is done according to the following rules:
*
* 1. Some special character escape sequences are recognized.
* For example, "\n" is a newline character, "\0" is ASCII NULL.
* 2. A specific treatment is applied to "\%" and "\_" sequences.
* This is due to their special meaning for pattern matching.
* 3. Other backslash-prefixed characters resolve to their literal
* values. For example, "\x" represents "x", "\\" represents "\".
*
* Despite looking similar, these rules are different from the C-style
* string escaping, so we cannot use "strip(c)slashes()" in this case.
*
* See: https://dev.mysql.com/doc/refman/8.4/en/string-literals.html
*/
$backslash = chr( 92 );
$replacements = array(
/*
* MySQL special character escape sequences.
*/
( $backslash . '0' ) => chr( 0 ), // An ASCII NULL character (\0).
( $backslash . "'" ) => chr( 39 ), // A single quote character (').
( $backslash . '"' ) => chr( 34 ), // A double quote character (").
( $backslash . 'b' ) => chr( 8 ), // A backspace character.
( $backslash . 'n' ) => chr( 10 ), // A newline (linefeed) character (\n).
( $backslash . 'r' ) => chr( 13 ), // A carriage return character (\r).
( $backslash . 't' ) => chr( 9 ), // A tab character (\t).
( $backslash . 'Z' ) => chr( 26 ), // An ASCII 26 (Control+Z) character.
/*
* Normalize escaping of "%" and "_" characters.
*
* MySQL has unusual handling for "\%" and "\_" in all string literals.
* While other sequences follow the C-style escaping ("\?" is "?", etc.),
* "\%" resolves to "\%" and "\_" resolves to "\_" (unlike in C strings).
*
* This means that "\%" behaves like "\\%", and "\_" behaves like "\\_".
* To preserve this behavior, we need to add a second backslash here.
*
* From https://dev.mysql.com/doc/refman/8.4/en/string-literals.html:
* > The \% and \_ sequences are used to search for literal instances
* > of % and _ in pattern-matching contexts where they would otherwise
* > be interpreted as wildcard characters. If you use \% or \_ outside
* > of pattern-matching contexts, they evaluate to the strings \% and
* > \_, not to % and _.
*/
( $backslash . '%' ) => $backslash . $backslash . '%',
( $backslash . '_' ) => $backslash . $backslash . '_',
/*
* Preserve a double backslash as-is, so that the trailing backslash
* is not consumed as the beginning of an escape sequence like "\n".
*
* Resolving "\\" to "\" will be handled in the next step, where all
* other backslash-prefixed characters resolve to their literal values.
*/
( $backslash . $backslash )
=> $backslash . $backslash,
/*
* The bounding quotes can also be escaped by being doubled.
*/
( $quote . $quote ) => $quote,
);
/*
* Apply the replacements.
*
* It is important to use "strtr()" and not "str_replace()", because
* "str_replace()" applies replacements one after another, modifying
* intermediate changes rather than just the original string:
*
* - str_replace( [ 'a', 'b' ], [ 'b', 'c' ], 'ab' ); // 'cc' (bad)
* - strtr( 'ab', [ 'a' => 'b', 'b' => 'c' ] ); // 'bc' (good)
*/
$value = strtr( $value, $replacements );
/*
* A backslash with any other character represents the character itself.
* That is, \x evaluates to x, \\ evaluates to \, and \🙂 evaluates to 🙂.
*/
$preg_quoted_backslash = preg_quote( $backslash );
$value = preg_replace( "/$preg_quoted_backslash(.)/u", '$1', $value );
}
return $value;
}
/**
* Get the token representation as a string.
*
* This method is intended to be used only for testing and debugging purposes,
* when tokens need to be presented in a human-readable form. It should not
* be used in production code, as it's not performance-optimized.
*
* @return string
*/
public function __toString(): string {
return $this->get_value() . '<' . $this->id . ',' . $this->get_name() . '>';
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<?php
class WP_MySQL_Lexer extends WP_MySQL_Native_Lexer {}
@@ -0,0 +1,179 @@
<?php
/**
* Parser node backed by a native (Rust) AST.
*
* Constructed by the native MySQL parser extension. Read methods delegate
* into the Rust-owned AST so children are never copied into PHP unless a
* caller actually walks the tree. On the first mutation (append_child or
* merge_fragment), the node materializes its children into the inherited
* `$children` array and behaves like a plain WP_Parser_Node from then on.
*/
class WP_MySQL_Native_Parser_Node extends WP_Parser_Node {
private $was_mutated = false;
public function __construct( $rule_id, $rule_name ) {
parent::__construct( $rule_id, $rule_name );
}
public function __destruct() {
if ( function_exists( 'wp_sqlite_mysql_native_ast_release_wrapper' ) ) {
wp_sqlite_mysql_native_ast_release_wrapper( $this );
}
}
/** @inheritDoc */
public function append_child( $node ) {
$this->materialize_native_children();
parent::append_child( $node );
}
/** @inheritDoc */
public function merge_fragment( $node ) {
$this->materialize_native_children();
if ( $node instanceof self ) {
$node->materialize_native_children();
}
parent::merge_fragment( $node );
}
/** @inheritDoc */
public function has_child(): bool {
if ( $this->was_mutated ) {
return parent::has_child();
}
return wp_sqlite_mysql_native_ast_has_child( $this );
}
/** @inheritDoc */
public function has_child_node( ?string $rule_name = null ): bool {
if ( $this->was_mutated ) {
return parent::has_child_node( $rule_name );
}
return wp_sqlite_mysql_native_ast_has_child_node( $this, $rule_name );
}
/** @inheritDoc */
public function has_child_token( ?int $token_id = null ): bool {
if ( $this->was_mutated ) {
return parent::has_child_token( $token_id );
}
return wp_sqlite_mysql_native_ast_has_child_token( $this, $token_id );
}
/** @inheritDoc */
public function get_first_child() {
if ( $this->was_mutated ) {
return parent::get_first_child();
}
return wp_sqlite_mysql_native_ast_get_first_child( $this );
}
/** @inheritDoc */
public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_Node {
if ( $this->was_mutated ) {
return parent::get_first_child_node( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_first_child_node( $this, $rule_name );
}
/** @inheritDoc */
public function get_first_child_token( ?int $token_id = null ): ?WP_Parser_Token {
if ( $this->was_mutated ) {
return parent::get_first_child_token( $token_id );
}
return wp_sqlite_mysql_native_ast_get_first_child_token( $this, $token_id );
}
/** @inheritDoc */
public function get_first_descendant_node( ?string $rule_name = null ): ?WP_Parser_Node {
if ( $this->was_mutated ) {
return parent::get_first_descendant_node( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_first_descendant_node( $this, $rule_name );
}
/** @inheritDoc */
public function get_first_descendant_token( ?int $token_id = null ): ?WP_Parser_Token {
if ( $this->was_mutated ) {
return parent::get_first_descendant_token( $token_id );
}
return wp_sqlite_mysql_native_ast_get_first_descendant_token( $this, $token_id );
}
/** @inheritDoc */
public function get_children(): array {
if ( $this->was_mutated ) {
return parent::get_children();
}
return wp_sqlite_mysql_native_ast_get_children( $this );
}
/** @inheritDoc */
public function get_child_nodes( ?string $rule_name = null ): array {
if ( $this->was_mutated ) {
return parent::get_child_nodes( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_child_nodes( $this, $rule_name );
}
/** @inheritDoc */
public function get_child_tokens( ?int $token_id = null ): array {
if ( $this->was_mutated ) {
return parent::get_child_tokens( $token_id );
}
return wp_sqlite_mysql_native_ast_get_child_tokens( $this, $token_id );
}
/** @inheritDoc */
public function get_descendants(): array {
if ( $this->was_mutated ) {
return parent::get_descendants();
}
return wp_sqlite_mysql_native_ast_get_descendants( $this );
}
/** @inheritDoc */
public function get_descendant_nodes( ?string $rule_name = null ): array {
if ( $this->was_mutated ) {
return parent::get_descendant_nodes( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_descendant_nodes( $this, $rule_name );
}
/** @inheritDoc */
public function get_descendant_tokens( ?int $token_id = null ): array {
if ( $this->was_mutated ) {
return parent::get_descendant_tokens( $token_id );
}
return wp_sqlite_mysql_native_ast_get_descendant_tokens( $this, $token_id );
}
/** @inheritDoc */
public function get_start(): int {
if ( $this->was_mutated ) {
return parent::get_start();
}
return wp_sqlite_mysql_native_ast_get_start( $this );
}
/** @inheritDoc */
public function get_length(): int {
if ( $this->was_mutated ) {
return parent::get_length();
}
return wp_sqlite_mysql_native_ast_get_length( $this );
}
private function materialize_native_children(): void {
if ( $this->was_mutated ) {
return;
}
$this->children = wp_sqlite_mysql_native_ast_get_children( $this );
$this->was_mutated = true;
if ( function_exists( 'wp_sqlite_mysql_native_ast_materialize_wrapper' ) ) {
wp_sqlite_mysql_native_ast_materialize_wrapper( $this );
}
}
}
@@ -0,0 +1,14 @@
<?php
/**
* Native-mode public parser entry point.
*
* Always extends the pure-PHP `WP_Parser` so `$parser instanceof WP_Parser`
* keeps working for callers regardless of whether the Rust extension is
* loaded. The actual parsing work is delegated to a composed
* `WP_MySQL_Native_Parser` instance via the `WP_MySQL_Native_Parser_Impl`
* trait — see that file for the per-method delegation.
*/
class WP_MySQL_Parser extends WP_Parser {
use WP_MySQL_Native_Parser_Impl;
}
@@ -0,0 +1,22 @@
<?php
/**
* Bridge helpers for the optional Rust MySQL lexer/parser extension.
* PHP keeps the grammar object, while Rust owns the exported parser state.
*/
/**
* Export grammar internals for the native parser.
*
* @param WP_Parser_Grammar $grammar Parser grammar.
* @return array<string, mixed>
*/
function wp_sqlite_mysql_native_export_grammar( WP_Parser_Grammar $grammar ): array {
return array(
'highest_terminal_id' => $grammar->highest_terminal_id,
'rules' => $grammar->rules,
'lookahead_is_match_possible' => $grammar->lookahead_is_match_possible,
'rule_names' => $grammar->rule_names,
'fragment_ids' => $grammar->fragment_ids,
);
}
@@ -0,0 +1,55 @@
<?php
/**
* Native-mode `WP_MySQL_Parser` implementation, delivered as a trait.
*
* The class that uses this trait (`WP_MySQL_Parser` in native mode)
* extends the pure-PHP `WP_Parser` so callers' `instanceof WP_Parser`
* checks keep working, while the actual parsing work is delegated to
* the Rust-registered `WP_MySQL_Native_Parser` instance held in
* `$this->native`. `WP_Parser`'s state (`$grammar`, `$tokens`,
* `$position`) stays inert in native mode — the trait's overrides
* never read it.
*
* Adding a public method here is enough to plumb a new public method
* through to the native parser; the using class does not need touching.
*/
trait WP_MySQL_Native_Parser_Impl {
/**
* @var WP_MySQL_Native_Parser
*/
private $native;
/**
* @param WP_Parser_Grammar $grammar
* @param array<WP_Parser_Token>|WP_MySQL_Native_Token_Stream $tokens
*/
public function __construct( WP_Parser_Grammar $grammar, $tokens ) {
// WP_Parser's `array $tokens` constructor signature can't accept
// the native token stream object; its `$this->tokens` /
// `$this->position` state is inert in native mode anyway, so we
// pass an empty array to satisfy the parent contract and keep
// the actual tokens on the native parser.
parent::__construct( $grammar, array() );
$this->native = new WP_MySQL_Native_Parser( $grammar, $tokens );
}
/**
* @param array<WP_Parser_Token>|WP_MySQL_Native_Token_Stream $tokens
*/
public function reset_tokens( $tokens ): void {
$this->native->reset_tokens( $tokens );
}
public function next_query(): bool {
return $this->native->next_query();
}
public function get_query_ast(): ?WP_Parser_Node {
return $this->native->get_query_ast();
}
public function parse() {
return $this->native->parse();
}
}
@@ -0,0 +1,140 @@
<?php
/**
* A parser grammar.
*
* This class represents a parser grammar that can be consumed by WP_Parser.
* It loads a compressed grammar from a PHP array, inflates it to an internal
* representation, and precomputes a lookup table for quick branch selection.
*
* @TODO: Add more details about the grammar implementation.
*/
class WP_Parser_Grammar {
/**
* ID for a special grammar rule that represents an empty "ε" (epsilon) rule.
*
* An "ε" rule in a grammar is a rule that matches an empty input of 0 bytes.
* It can be used to represent optional grammar productions, and it is helpful
* for expanding 0-or-1, 0-or-more, and 1-or-more quantifiers into simple rules.
*
* @TODO Investigate whether we can prevent possible conflict with a token ID.
* The MySQL grammar doesn't define a token with ID "0", but generally
* token IDs are not guaranteed to always satisfy this condition.
*/
const EMPTY_RULE_ID = 0;
/**
* @TODO: Review and document these properties and their visibility.
*/
public $rules;
public $rule_names;
public $fragment_ids;
public $lookahead_is_match_possible = array();
public $lowest_non_terminal_id;
public $highest_terminal_id;
public $native_grammar;
public function __construct( array $rules ) {
$this->inflate( $rules );
}
public function get_rule_name( $rule_id ) {
return $this->rule_names[ $rule_id ];
}
public function get_rule_id( $rule_name ) {
return array_search( $rule_name, $this->rule_names, true );
}
/**
* Inflate the grammar to an internal representation optimized for parsing.
*
* The input grammar is a compressed PHP array to minimize the file size.
* Every rule and token in the compressed grammar is encoded as an integer.
*/
private function inflate( $grammar ) {
$this->lowest_non_terminal_id = $grammar['rules_offset'];
$this->highest_terminal_id = $this->lowest_non_terminal_id - 1;
foreach ( $grammar['rules_names'] as $rule_index => $rule_name ) {
$this->rule_names[ $rule_index + $grammar['rules_offset'] ] = $rule_name;
$this->rules[ $rule_index + $grammar['rules_offset'] ] = array();
/**
* Treat all intermediate rules as fragments to inline before returning
* the final parse tree to the API consumer.
*
* The original grammar was too difficult to parse with rules like:
*
* query ::= EOF | ((simpleStatement | beginWork) ((SEMICOLON_SYMBOL EOF?) | EOF))
*
* We've factored rule fragments, such as `EOF?`, into separate rules, such as `%EOF_zero_or_one`.
* This is super useful for parsing, but it limits the API consumer's ability to
* reason about the parse tree.
*
* Fragments are intermediate rules that are not part of the original grammar.
* They are prefixed with a "%" to be distinguished from the original rules.
*/
if ( '%' === $rule_name[0] ) {
$this->fragment_ids[ $rule_index + $grammar['rules_offset'] ] = true;
}
}
$this->rules = array();
foreach ( $grammar['grammar'] as $rule_index => $branches ) {
$rule_id = $rule_index + $grammar['rules_offset'];
$this->rules[ $rule_id ] = $branches;
}
/**
* Compute a rule => [token => true] lookup table for each rule
* that starts with a terminal OR with another rule that already
* has a lookahead mapping.
*
* This is similar to left-factoring the grammar, even if not quite
* the same.
*
* This enables us to quickly bail out from checking branches that
* cannot possibly match the current token. This increased the parser
* speed by a whopping 80%!
*
* @TODO: Explore these possible next steps:
*
* * Compute a rule => [token => branch[]] list lookup table and only
* process the branches that have a chance of matching the current token.
* * Actually left-factor the grammar as much as possible. This, however,
* could inflate the serialized grammar size.
*/
// 5 iterations seem to give us all the speed gains we can get from this.
for ( $i = 0; $i < 5; $i++ ) {
foreach ( $grammar['grammar'] as $rule_index => $branches ) {
$rule_id = $rule_index + $grammar['rules_offset'];
if ( isset( $this->lookahead_is_match_possible[ $rule_id ] ) ) {
continue;
}
$rule_lookup = array();
$first_symbol_can_be_expanded_to_all_terminals = true;
foreach ( $branches as $branch ) {
$terminals = false;
$branch_starts_with_terminal = $branch[0] < $this->lowest_non_terminal_id;
if ( $branch_starts_with_terminal ) {
$terminals = array( $branch[0] );
} elseif ( isset( $this->lookahead_is_match_possible[ $branch[0] ] ) ) {
$terminals = array_keys( $this->lookahead_is_match_possible[ $branch[0] ] );
}
if ( false === $terminals ) {
$first_symbol_can_be_expanded_to_all_terminals = false;
break;
}
foreach ( $terminals as $terminal ) {
$rule_lookup[ $terminal ] = true;
}
}
if ( $first_symbol_can_be_expanded_to_all_terminals ) {
$this->lookahead_is_match_possible[ $rule_id ] = $rule_lookup;
}
}
}
}
}
@@ -0,0 +1,384 @@
<?php
/**
* A node in parse tree.
*
* This class represents a node in the parse tree that is produced by WP_Parser.
* A node corresponds to the related grammar rule that was matched by the parser.
* Each node can contain children, consisting of other nodes and grammar tokens.
* In this way, a parser node constitutes a recursive structure that represents
* a parse (sub)tree at each level of the full grammar tree.
*/
class WP_Parser_Node {
/**
* @TODO: Review and document these properties and their visibility.
*/
public $rule_id;
public $rule_name;
protected $children = array();
public function __construct( $rule_id, $rule_name ) {
$this->rule_id = $rule_id;
$this->rule_name = $rule_name;
}
public function append_child( $node ) {
$this->children[] = $node;
}
/**
* Flatten the matched rule fragments as if their children were direct
* descendants of the current rule.
*
* What are rule fragments?
*
* When we initially parse the grammar file, it has compound rules such
* as this one:
*
* query ::= EOF | ((simpleStatement | beginWork) ((SEMICOLON_SYMBOL EOF?) | EOF))
*
* Building a parser that can understand such rules is way more complex than building
* a parser that only follows simple rules, so we flatten those compound rules into
* simpler ones. The above rule would be flattened to:
*
* query ::= EOF | %query0
* %query0 ::= %%query01 %%query02
* %%query01 ::= simpleStatement | beginWork
* %%query02 ::= SEMICOLON_SYMBOL EOF_zero_or_one | EOF
* EOF_zero_or_one ::= EOF | ε
*
* This factorization happens in "convert-grammar.php".
*
* "Fragments" are intermediate artifacts whose names are not in the original grammar.
* They are extremely useful for the parser, but the API consumer should never have to
* worry about them. Fragment names start with a percent sign ("%").
*
* The code below inlines every fragment back in its parent rule.
*
* We could optimize this. The current $match may be discarded later on so any inlining
* effort here would be wasted. However, inlining seems cheap and doing it bottom-up here
* is **much** easier than reprocessing the parse tree top-down later on.
*
* The following parse tree:
*
* [
* 'query' => [
* [
* '%query01' => [
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ],
* '%query02' => [
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ]
* ],
* ]
* ]
* ]
* ]
* ]
*
* Would be inlined as:
*
* [
* 'query' => [
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ]
* ],
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ]
* ]
* ]
* ]
*/
public function merge_fragment( $node ) {
$this->children = array_merge( $this->children, $node->children );
}
/**
* Check if this node has any child nodes or tokens.
*
* @return bool True if this node has any child nodes or tokens, false otherwise.
*/
public function has_child(): bool {
return count( $this->children ) > 0;
}
/**
* Check if this node has any child nodes.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return bool True if any child nodes are found, false otherwise.
*/
public function has_child_node( ?string $rule_name = null ): bool {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Node
&& ( null === $rule_name || $child->rule_name === $rule_name )
) {
return true;
}
}
return false;
}
/**
* Check if this node has any child tokens.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return bool True if any child tokens are found, false otherwise.
*/
public function has_child_token( ?int $token_id = null ): bool {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Token
&& ( null === $token_id || $child->id === $token_id )
) {
return true;
}
}
return false;
}
/**
* Get the first child node or token of this node.
*
* @return WP_Parser_Node|WP_Parser_Token|null The first child node or token;
* null when no children are found.
*/
public function get_first_child() {
return $this->children[0] ?? null;
}
/**
* Get the first child node of this node.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node|null The first matching child node; null when no children are found.
*/
public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_Node {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Node
&& ( null === $rule_name || $child->rule_name === $rule_name )
) {
return $child;
}
}
return null;
}
/**
* Get the first child token of this node.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token|null The first matching child token; null when no children are found.
*/
public function get_first_child_token( ?int $token_id = null ): ?WP_Parser_Token {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Token
&& ( null === $token_id || $child->id === $token_id )
) {
return $child;
}
}
return null;
}
/**
* Get the first descendant node of this node.
*
* The node children are traversed recursively in a depth-first order until
* a matching descendant node is found, or the entire subtree is searched.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node|null The first matching descendant node; null when no descendants are found.
*/
public function get_first_descendant_node( ?string $rule_name = null ): ?WP_Parser_Node {
for ( $i = 0; $i < count( $this->children ); $i++ ) {
$child = $this->children[ $i ];
if ( ! $child instanceof WP_Parser_Node ) {
continue;
}
if ( null === $rule_name || $child->rule_name === $rule_name ) {
return $child;
}
$node = $child->get_first_descendant_node( $rule_name );
if ( $node ) {
return $node;
}
}
return null;
}
/**
* Get the first descendant token of this node.
*
* The node children are traversed recursively in a depth-first order until
* a matching descendant token is found, or the entire subtree is searched.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token|null The first matching descendant token; null when no descendants are found.
*/
public function get_first_descendant_token( ?int $token_id = null ): ?WP_Parser_Token {
for ( $i = 0; $i < count( $this->children ); $i++ ) {
$child = $this->children[ $i ];
if ( $child instanceof WP_Parser_Token ) {
if ( null === $token_id || $child->id === $token_id ) {
return $child;
}
} else {
$token = $child->get_first_descendant_token( $token_id );
if ( $token ) {
return $token;
}
}
}
return null;
}
/**
* Get all children of this node.
*
* @return array<WP_Parser_Node|WP_Parser_Token> An array of all child nodes and tokens of this node.
*/
public function get_children(): array {
return $this->children;
}
/**
* Get all child nodes of this node.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node[] An array of all matching child nodes.
*/
public function get_child_nodes( ?string $rule_name = null ): array {
$nodes = array();
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Node
&& ( null === $rule_name || $child->rule_name === $rule_name )
) {
$nodes[] = $child;
}
}
return $nodes;
}
/**
* Get all child tokens of this node.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token[] An array of all matching child tokens.
*/
public function get_child_tokens( ?int $token_id = null ): array {
$tokens = array();
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Token
&& ( null === $token_id || $child->id === $token_id )
) {
$tokens[] = $child;
}
}
return $tokens;
}
/**
* Get all descendants of this node.
*
* The descendants are collected using a depth-first pre-order NLR traversal.
* This produces a natural ordering that corresponds to the original input.
*
* @return array<WP_Parser_Node|WP_Parser_Token> An array of all descendant nodes and tokens of this node.
*/
public function get_descendants(): array {
$descendants = array();
foreach ( $this->children as $child ) {
if ( $child instanceof WP_Parser_Node ) {
$descendants[] = $child;
$descendants = array_merge( $descendants, $child->get_descendants() );
} else {
$descendants[] = $child;
}
}
return $descendants;
}
/**
* Get all descendant nodes of this node.
*
* The descendants are collected using a depth-first pre-order NLR traversal.
* This produces a natural ordering that corresponds to the original input.
* All matching nodes are collected during the traversal.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node[] An array of all matching descendant nodes.
*/
public function get_descendant_nodes( ?string $rule_name = null ): array {
$nodes = array();
foreach ( $this->children as $child ) {
if ( ! $child instanceof WP_Parser_Node ) {
continue;
}
if ( null === $rule_name || $child->rule_name === $rule_name ) {
$nodes[] = $child;
}
$nodes = array_merge( $nodes, $child->get_descendant_nodes( $rule_name ) );
}
return $nodes;
}
/**
* Get all descendant tokens of this node.
*
* The descendants are collected using a depth-first pre-order NLR traversal.
* This produces a natural ordering that corresponds to the original input.
* All matching tokens are collected during the traversal.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token[] An array of all matching descendant tokens.
*/
public function get_descendant_tokens( ?int $token_id = null ): array {
$tokens = array();
foreach ( $this->children as $child ) {
if ( $child instanceof WP_Parser_Token ) {
if ( null === $token_id || $child->id === $token_id ) {
$tokens[] = $child;
}
} else {
$tokens = array_merge( $tokens, $child->get_descendant_tokens( $token_id ) );
}
}
return $tokens;
}
/**
* Get the byte offset in the input string where this node begins.
*
* @return int The byte offset in the input string where this node begins.
*/
public function get_start(): int {
return $this->get_first_descendant_token()->start;
}
/**
* Get the byte length of this node in the input string.
*
* @return int The byte length of this node in the input string.
*/
public function get_length(): int {
$tokens = $this->get_descendant_tokens();
$first_token = $tokens[0];
$last_token = $tokens[ count( $tokens ) - 1 ];
return $last_token->start + $last_token->length - $first_token->start;
}
}
@@ -0,0 +1,77 @@
<?php
/**
* A token, representing a leaf in the parse tree.
*
* This class represents a token that is consumed and recognized by WP_Parser.
* In a parse tree, a token represent a leaf, that is, a node without children.
* It is a simple generic container for a token ID and value, that can be used
* as a base class and extended for specific use cases.
*/
class WP_Parser_Token {
/**
* Token ID represented as an integer constant.
*
* @var int $id
*/
public $id;
/**
* Byte offset in the input where the token begins.
*
* @var int
*/
public $start;
/**
* Byte length of the token in the input.
*
* @var int
*/
public $length;
/**
* Input bytes from which the token was parsed.
*
* @var string
*/
protected $input;
/**
* Constructor.
*
* @param int $id Token type.
* @param int $start Byte offset in the input where the token begins.
* @param int $length Byte length of the token in the input.
* @param string $input Input bytes from which the token was parsed.
*/
public function __construct(
int $id,
int $start,
int $length,
string $input
) {
$this->id = $id;
$this->start = $start;
$this->length = $length;
$this->input = $input;
}
/**
* Get the raw bytes of the token from the input.
*
* @return string The token bytes.
*/
public function get_bytes(): string {
return substr( $this->input, $this->start, $this->length );
}
/**
* Get the real unquoted value of the token.
*
* @return string The token value.
*/
public function get_value(): string {
return $this->get_bytes();
}
}
@@ -0,0 +1,124 @@
<?php
/**
* A recursive descent parser.
*
* This is a dynamic recursive descent parser that can parse LL grammars.
*
* @TODO: Add a detailed description and list the properties that a grammar must
* satisfy in order to be supported by this parser (e.g., no left recursion).
*/
class WP_Parser {
protected $grammar;
protected $tokens;
protected $position;
public function __construct( WP_Parser_Grammar $grammar, array $tokens ) {
$this->grammar = $grammar;
$this->tokens = $tokens;
$this->position = 0;
}
public function parse() {
// @TODO: Make the starting rule lookup non-grammar-specific.
$query_rule_id = $this->grammar->get_rule_id( 'query' );
$ast = $this->parse_recursive( $query_rule_id );
return false === $ast ? null : $ast;
}
private function parse_recursive( $rule_id ) {
$is_terminal = $rule_id <= $this->grammar->highest_terminal_id;
if ( $is_terminal ) {
if ( $this->position >= count( $this->tokens ) ) {
return false;
}
if ( WP_Parser_Grammar::EMPTY_RULE_ID === $rule_id ) {
return true;
}
if ( $this->tokens[ $this->position ]->id === $rule_id ) {
++$this->position;
return $this->tokens[ $this->position - 1 ];
}
return false;
}
$branches = $this->grammar->rules[ $rule_id ];
if ( ! count( $branches ) ) {
return false;
}
// Bale out from processing the current branch if none of its rules can
// possibly match the current token.
if ( isset( $this->grammar->lookahead_is_match_possible[ $rule_id ] ) ) {
$token_id = $this->tokens[ $this->position ]->id;
if (
! isset( $this->grammar->lookahead_is_match_possible[ $rule_id ][ $token_id ] ) &&
! isset( $this->grammar->lookahead_is_match_possible[ $rule_id ][ WP_Parser_Grammar::EMPTY_RULE_ID ] )
) {
return false;
}
}
$rule_name = $this->grammar->rule_names[ $rule_id ];
$starting_position = $this->position;
foreach ( $branches as $branch ) {
$this->position = $starting_position;
$node = new WP_Parser_Node( $rule_id, $rule_name );
$branch_matches = true;
foreach ( $branch as $subrule_id ) {
$subnode = $this->parse_recursive( $subrule_id );
if ( false === $subnode ) {
$branch_matches = false;
break;
} elseif ( true === $subnode ) {
/*
* The subrule was matched without actually matching a token.
* This means a special empty "ε" (epsilon) rule was matched.
* An "ε" rule in a grammar matches an empty input of 0 bytes.
* It is used to represent optional grammar productions.
*/
continue;
} elseif ( is_array( $subnode ) && 0 === count( $subnode ) ) {
continue;
}
if ( is_array( $subnode ) && ! count( $subnode ) ) {
continue;
}
if ( isset( $this->grammar->fragment_ids[ $subrule_id ] ) ) {
$node->merge_fragment( $subnode );
} else {
$node->append_child( $subnode );
}
}
// Negative lookahead for INTO after a valid SELECT statement.
// If we match a SELECT statement, but there is an INTO keyword after it,
// we're in the wrong branch and need to leave matching to a later rule.
// @TODO: Extract this to the "WP_MySQL_Parser" class, or add support
// for right-associative rules, which could solve this.
// See: https://github.com/mysql/mysql-workbench/blob/8.0.38/library/parsers/grammars/MySQLParser.g4#L994
// See: https://github.com/antlr/antlr4/issues/488
$la = $this->tokens[ $this->position ] ?? null;
if ( $la && 'selectStatement' === $rule_name && WP_MySQL_Lexer::INTO_SYMBOL === $la->id ) {
$branch_matches = false;
}
if ( true === $branch_matches ) {
break;
}
}
if ( ! $branch_matches ) {
$this->position = $starting_position;
return false;
}
if ( ! $node->has_child() ) {
return true;
}
return $node;
}
}
@@ -0,0 +1,68 @@
<?php
/**
* Polyfills for PHP 8.0 string functions.
*
* Implementation follows the Symfony polyfill-php80 package.
*
* @see https://github.com/symfony/polyfill-php80
*
* @package wp-sqlite-integration
*/
if ( ! function_exists( 'str_starts_with' ) ) {
/**
* Check if a string starts with a specific substring.
*
* @param string $haystack The string to search in.
* @param string $needle The string to search for.
*
* @see https://www.php.net/manual/en/function.str-starts-with
*
* @return bool
*/
function str_starts_with( string $haystack, string $needle ) {
return 0 === strncmp( $haystack, $needle, strlen( $needle ) );
}
}
if ( ! function_exists( 'str_contains' ) ) {
/**
* Check if a string contains a specific substring.
*
* @param string $haystack The string to search in.
* @param string $needle The string to search for.
*
* @see https://www.php.net/manual/en/function.str-contains
*
* @return bool
*/
function str_contains( string $haystack, string $needle ) {
return '' === $needle || false !== strpos( $haystack, $needle );
}
}
if ( ! function_exists( 'str_ends_with' ) ) {
/**
* Check if a string ends with a specific substring.
*
* @param string $haystack The string to search in.
* @param string $needle The string to search for.
*
* @see https://www.php.net/manual/en/function.str-ends-with
*
* @return bool
*/
function str_ends_with( string $haystack, string $needle ) {
if ( '' === $needle || $needle === $haystack ) {
return true;
}
if ( '' === $haystack ) {
return false;
}
$needle_length = strlen( $needle );
return $needle_length <= strlen( $haystack ) && 0 === substr_compare( $haystack, $needle, -$needle_length );
}
}
@@ -0,0 +1,382 @@
<?php
/*
* The SQLite driver uses PDO. Enable PDO function calls:
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDOStatement
*
* PDO uses camel case naming, enable non-snake case:
* phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
* phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
*
* PDO uses $class as a variable name, enable it:
* phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.classFound
*
* Some PDOStatement methods use $var as a variable name, enable it:
* phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.varFound
*
* We use traits to support different PHP versions with incompatible PDO statement
* method signatures. For that, enable multiple object structures in one file:
* phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
*/
/**
* Some PDOStatement methods are not compatible across different PHP versions.
* To address "Declaration of ... should be compatible with ..." PHP warnings,
* we conditionally define traits with different APIs based on the PHP version.
*/
if ( PHP_VERSION_ID < 80000 ) {
trait WP_PDO_Proxy_Statement_PHP_Compat {
/**
* Set the default fetch mode for this statement.
*
* @param int $mode The fetch mode to set as the default.
* @param mixed $params Additional parameters for the default fetch mode.
* @return bool True on success, false on failure.
*/
public function setFetchMode( $mode, $params = null ): bool {
// Do not pass additional arguments when they are NULL to prevent
// "fetch mode doesn't allow any extra arguments" error.
if ( null === $params ) {
return $this->setDefaultFetchMode( $mode );
}
return $this->setDefaultFetchMode( $mode, $params );
}
/**
* Fetch all remaining rows from the result set.
*
* @param int $mode The fetch mode to use.
* @param mixed $class_name With PDO::FETCH_CLASS, the name of the class to instantiate.
* @param mixed $constructor_args With PDO::FETCH_CLASS, the parameters to pass to the class constructor.
* @return array The result set as an array of rows.
*/
public function fetchAll( $mode = null, $class_name = null, $constructor_args = null ): array {
// Do not pass additional arguments when they are NULL to prevent
// "Extraneous additional parameters" error.
if ( null === $class_name && null === $constructor_args ) {
return $this->fetchAllRows( $mode );
}
return $this->fetchAllRows( $mode, $class_name, $constructor_args );
}
}
} else {
trait WP_PDO_Proxy_Statement_PHP_Compat {
/**
* Set the default fetch mode for this statement.
*
* @param int $mode The fetch mode to set as the default.
* @param mixed $args Additional parameters for the default fetch mode.
* @return bool True on success, false on failure.
*/
#[ReturnTypeWillChange]
public function setFetchMode( $mode, ...$args ): bool {
return $this->setDefaultFetchMode( $mode, ...$args );
}
/**
* Fetch all remaining rows from the result set.
*
* @param int $mode The fetch mode to use.
* @param mixed $args Additional parameters for the fetch mode.
* @return array The result set as an array of rows.
*/
public function fetchAll( $mode = PDO::FETCH_DEFAULT, ...$args ): array {
return $this->fetchAllRows( $mode, ...$args );
}
}
}
/**
* PDOStatement implementation that operates on in-memory data.
*
* This class implements a complete PDOStatement interface on top of PHP arrays.
* It is used for result sets that are composed or transformed in the PHP layer.
*
* PDO supports the following fetch modes:
* - PDO::FETCH_DEFAULT: current default fetch mode (available from PHP 8.0)
* - PDO::FETCH_BOTH: default
* - PDO::FETCH_NUM: numeric array
* - PDO::FETCH_ASSOC: associative array
* - PDO::FETCH_NAMED: associative array retaining duplicate columns
* - PDO::FETCH_COLUMN: single column value [1 extra arg]
* - PDO::FETCH_KEY_PAIR: key-value pair
* - PDO::FETCH_OBJ: object (stdClass)
* - PDO::FETCH_CLASS: object (custom class) [1-2 extra args]
* - PDO::FETCH_INTO: update an exisisting object, can't be used with fetchAll() [1 extra arg]
* - PDO::FETCH_LAZY: lazy fetch via PDORow, can't be used with fetchAll()
* - PDO::FETCH_BOUND: bind values to PHP variables, can't be used with fetchAll()
* - PDO::FETCH_FUNC: custom function, only works with fetchAll(), can't be default [1 extra arg]
*/
class WP_PDO_Proxy_Statement extends PDOStatement {
use WP_PDO_Proxy_Statement_PHP_Compat;
/**
* The original PDO statement.
*
* @var PDOStatement
*/
private $statement;
/**
* The number of affected rows.
*
* @var int|null
*/
private $affected_rows;
/**
* Constructor.
*
* @param PDOStatement $statement The original PDO statement.
* @param int $affected_rows The number of affected rows.
*/
public function __construct(
PDOStatement $statement,
?int $affected_rows = null
) {
$this->statement = $statement;
$this->affected_rows = $affected_rows;
}
/**
* Execute a prepared statement.
*
* @param mixed $params The values to bind to the parameters of the prepared statement.
* @return bool True on success, false on failure.
*/
public function execute( $params = null ): bool {
return $this->statement->execute( $params );
}
/**
* Get the number of columns in the result set.
*
* @return int The number of columns in the result set.
*/
public function columnCount(): int {
return $this->statement->columnCount();
}
/**
* Get the number of rows affected by the statement.
*
* @return int The number of rows affected by the statement.
*/
public function rowCount(): int {
return $this->affected_rows ?? $this->statement->rowCount();
}
/**
* Fetch the next row from the result set.
*
* @param int|null $mode The fetch mode. Controls how the row is returned.
* Default: PDO::FETCH_DEFAULT (null for PHP < 8.0)
* @param int|null $cursorOrientation The cursor orientation. Controls which row is returned.
* Default: PDO::FETCH_ORI_NEXT (null for PHP < 8.0)
* @param int|null $cursorOffset The cursor offset. Controls which row is returned.
* Default: 0 (null for PHP < 8.0)
* @return mixed The row data formatted according to the fetch mode;
* false if there are no more rows or a failure occurs.
*/
#[ReturnTypeWillChange]
public function fetch(
$mode = 0, // PDO::FETCH_DEFAULT (available from PHP 8.0)
$cursorOrientation = 0,
$cursorOffset = 0
) {
return $this->statement->fetch( $mode, $cursorOrientation, $cursorOffset );
}
/**
* Fetch a single column from the next row of a result set.
*
* @param int $column The index of the column to fetch (0-indexed).
* @return mixed The value of the column; false if there are no more rows.
*/
#[ReturnTypeWillChange]
public function fetchColumn( $column = 0 ) {
return $this->statement->fetchColumn( $column );
}
/**
* Fetch the next row as an object.
*
* @param string $class The name of the class to instantiate.
* @param array $constructorArgs The parameters to pass to the class constructor.
* @return object The next row as an object.
*/
#[ReturnTypeWillChange]
public function fetchObject( $class = 'stdClass', $constructorArgs = array() ) {
return $this->statement->fetchObject( $class, $constructorArgs );
}
/**
* Get metadata for a column in a result set.
*
* @param int $column The index of the column (0-indexed).
* @return array|false The column metadata as an associative array,
* or false if the column does not exist.
*/
public function getColumnMeta( $column ): array {
throw new RuntimeException( 'Not implemented' );
}
/**
* Fetch the SQLSTATE associated with the last statement operation.
*
* @return string|null The SQLSTATE error code (as defined by the ANSI SQL standard),
* or null if there is no error.
*/
public function errorCode(): ?string {
throw new RuntimeException( 'Not implemented' );
}
/**
* Fetch error information associated with the last statement operation.
*
* @return array The array consists of at least the following fields:
* 0: SQLSTATE error code (as defined by the ANSI SQL standard).
* 1: Driver-specific error code.
* 2: Driver-specific error message.
*/
public function errorInfo(): array {
throw new RuntimeException( 'Not implemented' );
}
/**
* Get a statement attribute.
*
* @param int $attribute The attribute to get.
* @return mixed The value of the attribute.
*/
#[ReturnTypeWillChange]
public function getAttribute( $attribute ) {
return $this->statement->getAttribute( $attribute );
}
/**
* Set a statement attribute.
*
* @param int $attribute The attribute to set.
* @param mixed $value The value of the attribute.
* @return bool True on success, false on failure.
*/
public function setAttribute( $attribute, $value ): bool {
return $this->statement->setAttribute( $attribute, $value );
}
/**
* Get result set as iterator.
*
* @return Iterator The iterator for the result set.
*/
public function getIterator(): Iterator {
throw new RuntimeException( 'Not implemented' );
}
/**
* Advances to the next rowset in a multi-rowset statement handle.
*
* @return bool True on success, false on failure.
*/
public function nextRowset(): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Closes the cursor, enabling the statement to be executed again.
*
* @return bool True on success, false on failure.
*/
public function closeCursor(): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Bind a column to a PHP variable.
*
* @param int|string $column Number of the column (1-indexed) or name of the column in the result set.
* @param mixed $var PHP variable to which the column will be bound.
* @param int $type Data type of the parameter, specified by the PDO::PARAM_* constants.
* @param int $maxLength A hint for pre-allocation.
* @param mixed $driverOptions Optional parameters for the driver.
* @return bool True on success, false on failure.
*/
public function bindColumn( $column, &$var, $type = null, $maxLength = null, $driverOptions = null ): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Bind a parameter to a PHP variable.
*
* @param int|string $param Parameter identifier. Either a 1-indexed position of the parameter or a named parameter.
* @param mixed $var PHP variable to which the parameter will be bound.
* @param int $type Data type of the parameter, specified by the PDO::PARAM_* constants.
* @param int $maxLength Length of the data type.
* @param mixed $driverOptions Optional parameters for the driver.
* @return bool True on success, false on failure.
*/
public function bindParam( $param, &$var, $type = PDO::PARAM_STR, $maxLength = 0, $driverOptions = null ): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Bind a value to a parameter.
*
* @param int|string $param Parameter identifier. Either a 1-indexed position of the parameter or a named parameter.
* @param mixed $value The value to bind to the parameter.
* @param int $type Data type of the parameter, specified by the PDO::PARAM_* constants.
* @return bool True on success, false on failure.
*/
public function bindValue( $param, $value, $type = PDO::PARAM_STR ): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Dump information about the statement.
*
* Dupms the SQL query and parameters information.
*
* @return bool|null Returns null, or false on failure.
*/
public function debugDumpParams(): ?bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Fetch all remaining rows from the result set.
*
* This is used internally by the "WP_PDO_Proxy_Statement_PHP_Compat" trait,
* that is defined conditionally based on the current PHP version.
*
* @param int $mode The fetch mode to use.
* @param mixed $args Additional parameters for the fetch mode.
* @return array The result set as an array of rows.
*/
private function fetchAllRows( $mode = null, ...$args ): array {
return $this->statement->fetchAll( $mode, ...$args );
}
/**
* Set the default fetch mode for this statement.
*
* This is used internally by the "WP_PDO_Proxy_Statement_PHP_Compat" trait,
* that is defined conditionally based on the current PHP version.
*
* @param int $mode The fetch mode to set as the default.
* @param mixed $args Additional parameters for the default fetch mode.
* @return bool True on success, false on failure.
*/
private function setDefaultFetchMode( $mode, ...$args ): bool {
return $this->statement->setFetchMode( $mode, ...$args );
}
}
/**
* Polyfill ValueError for PHP < 8.0.
*/
if ( PHP_VERSION_ID < 80000 && ! class_exists( ValueError::class ) ) {
class ValueError extends Error {
}
}
@@ -0,0 +1,273 @@
<?php
/**
* SQLite database configurator.
*
* This class initializes and configures the SQLite database, so that it can be
* used by the SQLite driver to translate and emulate MySQL queries in SQLite.
*
* The configurator ensures that tables required for emulating MySQL behaviors
* are created and populated with necessary data. It is also able to partially
* repair and update these tables and metadata in case of database corruption.
*/
class WP_SQLite_Configurator {
/**
* The SQLite driver instance.
*
* @var WP_PDO_MySQL_On_SQLite
*/
private $driver;
/**
* A service for managing MySQL INFORMATION_SCHEMA tables in SQLite.
*
* @var WP_SQLite_Information_Schema_Builder
*/
private $schema_builder;
/**
* A service for reconstructing the MySQL INFORMATION_SCHEMA tables in SQLite.
*
* @var WP_SQLite_Information_Schema_Reconstructor
*/
private $schema_reconstructor;
/**
* Constructor.
*
* @param WP_PDO_MySQL_On_SQLite $driver The SQLite driver instance.
* @param WP_SQLite_Information_Schema_Builder $schema_builder The information schema builder instance.
*/
public function __construct(
WP_PDO_MySQL_On_SQLite $driver,
WP_SQLite_Information_Schema_Builder $schema_builder
) {
$this->driver = $driver;
$this->schema_builder = $schema_builder;
$this->schema_reconstructor = new WP_SQLite_Information_Schema_Reconstructor(
$driver,
$schema_builder
);
}
/**
* Ensure that the SQLite database is configured.
*
* This method checks if the database is configured for the latest SQLite
* driver version, and if it is not, it will configure the database.
*/
public function ensure_database_configured(): void {
$version = SQLITE_DRIVER_VERSION;
$db_version = $this->driver->get_saved_driver_version();
if ( version_compare( $version, $db_version ) > 0 ) {
$this->configure_database();
}
}
/**
* Configure the SQLite database.
*
* This method creates tables used for emulating MySQL behaviors in SQLite,
* and populates them with necessary data. When it is used with an already
* configured database, it will update the configuration as per the current
* SQLite driver version and attempt to repair any configuration corruption.
*/
public function configure_database(): void {
// Use an EXCLUSIVE transaction to prevent multiple connections
// from attempting to configure the database at the same time.
$this->driver->execute_sqlite_query( 'BEGIN EXCLUSIVE TRANSACTION' );
try {
$this->ensure_global_variables_table();
$this->schema_builder->ensure_information_schema_tables();
$this->schema_reconstructor->ensure_correct_information_schema();
$this->save_current_driver_version();
$this->ensure_database_data();
} catch ( Throwable $e ) {
$this->driver->execute_sqlite_query( 'ROLLBACK' );
throw $e;
}
$this->driver->execute_sqlite_query( 'COMMIT' );
}
/**
* Ensure that the global variables table exists.
*
* This method configures a database table to store MySQL global variables
* and other internal configuration values.
*/
private function ensure_global_variables_table(): void {
$this->driver->execute_sqlite_query(
sprintf(
'CREATE TABLE IF NOT EXISTS %s (name TEXT PRIMARY KEY, value TEXT)',
$this->driver->get_connection()->quote_identifier(
WP_PDO_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME
)
)
);
}
/**
* Ensure that the database data is correctly populated.
*
* This method ensures that the "INFORMATION_SCHEMA.SCHEMATA" table contains
* records for both the "INFORMATION_SCHEMA" database and the user database.
* At the moment, only a single user database is supported.
*
* Additionally, this method ensures that the user database name is stored
* correctly in all the information schema tables.
*/
public function ensure_database_data(): void {
// Get all databases from the "SCHEMATA" table.
$schemata_table = $this->schema_builder->get_table_name( false, 'schemata' );
$databases = $this->driver->execute_sqlite_query(
sprintf(
'SELECT SCHEMA_NAME FROM %s',
$this->driver->get_connection()->quote_identifier( $schemata_table )
)
)->fetchAll( PDO::FETCH_COLUMN ); // phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
// Ensure that the "INFORMATION_SCHEMA" database record exists.
if ( ! in_array( 'information_schema', $databases, true ) ) {
$this->driver->execute_sqlite_query(
sprintf(
'INSERT INTO %s (SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME) VALUES (?, ?, ?)',
$this->driver->get_connection()->quote_identifier( $schemata_table )
),
// The "INFORMATION_SCHEMA" database stays on "utf8mb3" even in MySQL 8 and 9.
array( 'information_schema', 'utf8mb3', 'utf8mb3_general_ci' )
);
}
// Get the existing user database name.
$existing_user_db_name = null;
foreach ( $databases as $database ) {
if ( 'information_schema' !== strtolower( $database ) ) {
$existing_user_db_name = $database;
break;
}
}
// Ensure that the user database record exists.
if ( null === $existing_user_db_name ) {
$existing_user_db_name = WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME;
$this->driver->execute_sqlite_query(
sprintf(
'INSERT INTO %s (SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME) VALUES (?, ?, ?)',
$this->driver->get_connection()->quote_identifier( $schemata_table )
),
// @TODO: This should probably be version-dependent.
// Before MySQL 8, the default was different.
array( $existing_user_db_name, 'utf8mb4', 'utf8mb4_0900_ai_ci' )
);
}
// Migrate from older versions without dynamic database names.
$saved_database_name = WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME;
if ( $saved_database_name !== $existing_user_db_name ) {
// INFORMATION_SCHEMA.SCHEMATA
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET SCHEMA_NAME = ? WHERE SCHEMA_NAME != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $schemata_table )
),
array( $saved_database_name )
);
// INFORMATION_SCHEMA.TABLES
$tables_table = $this->schema_builder->get_table_name( false, 'tables' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET TABLE_SCHEMA = ? WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $tables_table )
),
array( $saved_database_name )
);
// INFORMATION_SCHEMA.COLUMNS
$columns_table = $this->schema_builder->get_table_name( false, 'columns' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET TABLE_SCHEMA = ? WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $columns_table )
),
array( $saved_database_name )
);
// INFORMATION_SCHEMA.STATISTICS
$statistics_table = $this->schema_builder->get_table_name( false, 'statistics' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET TABLE_SCHEMA = ?, INDEX_SCHEMA = ? WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $statistics_table )
),
array( $saved_database_name, $saved_database_name )
);
// INFORMATION_SCHEMA.TABLE_CONSTRAINTS
$table_constraints_table = $this->schema_builder->get_table_name( false, 'table_constraints' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET TABLE_SCHEMA = ?, CONSTRAINT_SCHEMA = ? WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $table_constraints_table )
),
array( $saved_database_name, $saved_database_name )
);
// INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
$referential_constraints_table = $this->schema_builder->get_table_name( false, 'referential_constraints' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET CONSTRAINT_SCHEMA = ?, UNIQUE_CONSTRAINT_SCHEMA = ? WHERE CONSTRAINT_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $referential_constraints_table )
),
array( $saved_database_name, $saved_database_name )
);
// INFORMATION_SCHEMA.KEY_COLUMN_USAGE
$key_column_usage_table = $this->schema_builder->get_table_name( false, 'key_column_usage' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s
SET
TABLE_SCHEMA = ?,
CONSTRAINT_SCHEMA = ?,
REFERENCED_TABLE_SCHEMA = CASE WHEN REFERENCED_TABLE_SCHEMA IS NULL THEN NULL ELSE ? END
WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $key_column_usage_table )
),
array( $saved_database_name, $saved_database_name, $saved_database_name )
);
// INFORMATION_SCHEMA.CHECK_CONSTRAINTS
$check_constraints_table = $this->schema_builder->get_table_name( false, 'check_constraints' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET CONSTRAINT_SCHEMA = ? WHERE CONSTRAINT_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $check_constraints_table )
),
array( $saved_database_name )
);
}
}
/**
* Save the current SQLite driver version.
*
* This method saves the current SQLite driver version to the database.
*/
private function save_current_driver_version(): void {
$this->driver->execute_sqlite_query(
sprintf(
'INSERT INTO %s (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = ?',
$this->driver->get_connection()->quote_identifier(
WP_PDO_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME
)
),
array(
WP_PDO_MySQL_On_SQLite::DRIVER_VERSION_VARIABLE_NAME,
SQLITE_DRIVER_VERSION,
SQLITE_DRIVER_VERSION,
)
);
}
}
@@ -0,0 +1,210 @@
<?php declare(strict_types = 1);
/*
* The SQLite connection uses PDO. Enable PDO function calls:
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
*/
/**
* SQLite connection.
*
* This class configures and encapsulates the connection to an SQLite database.
* It requires PDO with the SQLite driver, and currently, it is only a simple
* wrapper that leaks some of the PDO APIs (returns PDOStatement values, etc.).
* In the future, we may abstract it away from PDO and support SQLite3 as well.
*/
class WP_SQLite_Connection {
/**
* The default timeout in seconds for SQLite to wait for a writable lock.
*/
const DEFAULT_SQLITE_TIMEOUT = 10;
/**
* The supported SQLite journal modes.
*
* See: https://www.sqlite.org/pragma.html#pragma_journal_mode
*/
const SQLITE_JOURNAL_MODES = array(
'DELETE',
'TRUNCATE',
'PERSIST',
'MEMORY',
'WAL',
'OFF',
);
/**
* The PDO connection for SQLite.
*
* @var PDO
*/
private $pdo;
/**
* A query logger callback.
*
* @var callable(string, array): void
*/
private $query_logger;
/**
* Constructor.
*
* Set up an SQLite connection.
*
* @param array $options {
* An array of options.
*
* @type string|null $path Optional. SQLite database path.
* For in-memory database, use ':memory:'.
* Must be set when PDO instance is not provided.
* @type PDO|null $pdo Optional. PDO instance with SQLite connection.
* If not provided, a new PDO instance will be created.
* @type int|null $timeout Optional. SQLite timeout in seconds.
* The time to wait for a writable lock.
* @type string|null $journal_mode Optional. SQLite journal mode.
* }
*
* @throws InvalidArgumentException When some connection options are invalid.
* @throws PDOException When the driver initialization fails.
*/
public function __construct( array $options ) {
// Setup PDO connection.
if ( isset( $options['pdo'] ) && $options['pdo'] instanceof PDO ) {
$this->pdo = $options['pdo'];
} else {
if ( ! isset( $options['path'] ) || ! is_string( $options['path'] ) ) {
throw new InvalidArgumentException( 'Option "path" is required when "connection" is not provided.' );
}
$pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class;
$this->pdo = new $pdo_class( 'sqlite:' . $options['path'] );
}
// Throw exceptions on error.
$this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
// Configure SQLite timeout.
if ( isset( $options['timeout'] ) && is_int( $options['timeout'] ) ) {
$timeout = $options['timeout'];
} else {
$timeout = self::DEFAULT_SQLITE_TIMEOUT;
}
$this->pdo->setAttribute( PDO::ATTR_TIMEOUT, $timeout );
// Configure SQLite journal mode.
$journal_mode = $options['journal_mode'] ?? null;
if ( $journal_mode && in_array( $journal_mode, self::SQLITE_JOURNAL_MODES, true ) ) {
$this->query( 'PRAGMA journal_mode = ' . $journal_mode );
}
}
/**
* Execute a query in SQLite.
*
* @param string $sql The query to execute.
* @param array $params The query parameters.
* @throws PDOException When the query execution fails.
* @return PDOStatement The PDO statement object.
*/
public function query( string $sql, array $params = array() ): PDOStatement {
if ( $this->query_logger ) {
( $this->query_logger )( $sql, $params );
}
$stmt = $this->pdo->prepare( $sql );
$stmt->execute( $params );
return $stmt;
}
/**
* Prepare a SQLite query for execution.
*
* @param string $sql The query to prepare.
* @return PDOStatement The prepared statement.
* @throws PDOException When the query preparation fails.
*/
public function prepare( string $sql ): PDOStatement {
if ( $this->query_logger ) {
( $this->query_logger )( $sql, array() );
}
return $this->pdo->prepare( $sql );
}
/**
* Returns the ID of the last inserted row.
*
* @return string The ID of the last inserted row.
*/
public function get_last_insert_id(): string {
return $this->pdo->lastInsertId();
}
/**
* Quote a value for use in a query.
*
* @param mixed $value The value to quote.
* @param int $type The type of the value.
* @return string The quoted value.
*/
public function quote( $value, int $type = PDO::PARAM_STR ): string {
return $this->pdo->quote( $value, $type );
}
/**
* Quote an SQLite identifier.
*
* Wraps the identifier in backticks and escapes backtick characters within.
*
* ---
*
* Quoted identifiers in SQLite are represented by string constants:
*
* A string constant is formed by enclosing the string in single quotes (').
* A single quote within the string can be encoded by putting two single
* quotes in a row - as in Pascal. C-style escapes using the backslash
* character are not supported because they are not standard SQL.
*
* See: https://www.sqlite.org/lang_expr.html#literal_values_constants_
*
* Although sparsely documented, this applies to backtick and double quoted
* string constants as well, so only the quote character needs to be escaped.
*
* For more details, see the grammar for SQLite table and column names:
*
* - https://github.com/sqlite/sqlite/blob/873fc5dff2a781251f2c9bd2c791a5fac45b7a2b/src/tokenize.c#L395-L419
* - https://github.com/sqlite/sqlite/blob/873fc5dff2a781251f2c9bd2c791a5fac45b7a2b/src/parse.y#L321-L338
*
* ---
*
* We use backtick quotes instead of the SQL standard double quotes, due to
* an SQLite quirk causing double-quoted strings to be accepted as literals:
*
* This misfeature means that a misspelled double-quoted identifier will
* be interpreted as a string literal, rather than generating an error.
*
* See: https://www.sqlite.org/quirks.html#double_quoted_string_literals_are_accepted
*
* @param string $unquoted_identifier The unquoted identifier value.
* @return string The quoted identifier value.
*/
public function quote_identifier( string $unquoted_identifier ): string {
return '`' . str_replace( '`', '``', $unquoted_identifier ) . '`';
}
/**
* Get the PDO object.
*
* @return PDO
*/
public function get_pdo(): PDO {
return $this->pdo;
}
/**
* Set a logger for the queries.
*
* @param callable(string, array): void $logger A query logger callback.
*/
public function set_query_logger( callable $logger ): void {
$this->query_logger = $logger;
}
}
@@ -0,0 +1,33 @@
<?php
class WP_SQLite_Driver_Exception extends PDOException {
/**
* The SQLite driver that originated the exception.
*
* @var WP_PDO_MySQL_On_SQLite
*/
private $driver;
/**
* Constructor.
*
* @param WP_PDO_MySQL_On_SQLite $driver The SQLite driver that originated the exception.
* @param string $message The exception message.
* @param int|string $code The exception code. In PDO, it can be a string with value of SQLSTATE.
* @param Throwable|null $previous The previous throwable used for the exception chaining.
*/
public function __construct(
WP_PDO_MySQL_On_SQLite $driver,
string $message,
$code = 0,
?Throwable $previous = null
) {
parent::__construct( $message, 0, $previous );
$this->code = $code;
$this->driver = $driver;
}
public function getDriver(): WP_PDO_MySQL_On_SQLite {
return $this->driver;
}
}
@@ -0,0 +1,279 @@
<?php
/*
* The SQLite driver uses PDO. Enable PDO function calls:
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
*/
/**
* For back compatibility with dependencies that use their own loader scripts
* (e.g., WP CLI SQLite Command), ensure the new PDO-based classes are loaded.
*/
require_once __DIR__ . '/class-wp-pdo-mysql-on-sqlite.php';
require_once __DIR__ . '/class-wp-pdo-proxy-statement.php';
/**
* Deprecated: A proxy of the WP_PDO_MySQL_On_SQLite class preserving legacy API.
*
* This is a temporary class to preserve the legacy API for easier transition
* to the new PDO-based API, developed in the "WP_PDO_MySQL_On_SQLite" class.
*/
class WP_SQLite_Driver {
/**
* The SQLite engine version.
*
* This is a mysqli-like property that is needed to avoid a PHP warning in
* the WordPress health info. The "WP_Debug_Data::get_wp_database()" method
* calls "$wpdb->dbh->client_info" - a mysqli-specific abstraction leak.
*
* @TODO: This should be fixed in WordPress core.
*
* See:
* https://github.com/WordPress/wordpress-develop/blob/bcdca3f9925f1d3eca7b78d231837c0caf0c8c24/src/wp-admin/includes/class-wp-debug-data.php#L1579
*
* @var string
*/
public $client_info;
/**
* The MySQL-on-SQLite driver instance.
*
* @var WP_PDO_MySQL_On_SQLite
*/
private $mysql_on_sqlite_driver;
/**
* Results of the last emulated query.
*
* @var mixed
*/
private $last_result;
/**
* Constructor.
*
* Set up an SQLite connection and the MySQL-on-SQLite driver.
*
* @param WP_SQLite_Connection $connection A SQLite database connection.
* @param string $database The database name.
*
* @throws WP_SQLite_Driver_Exception When the driver initialization fails.
*/
public function __construct(
WP_SQLite_Connection $connection,
string $database,
int $mysql_version = 80038
) {
$this->mysql_on_sqlite_driver = new WP_PDO_MySQL_On_SQLite(
sprintf( 'mysql-on-sqlite:dbname=%s', $database ),
null,
null,
array(
'mysql_version' => $mysql_version,
'pdo' => $connection->get_pdo(),
)
);
$this->main_db_name = $database;
$this->client_info = $this->mysql_on_sqlite_driver->client_info;
$connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true );
}
/**
* Get the SQLite connection instance.
*
* @return WP_SQLite_Connection
*/
public function get_connection(): WP_SQLite_Connection {
return $this->mysql_on_sqlite_driver->get_connection();
}
/**
* Get the version of the SQLite engine.
*
* @return string SQLite engine version as a string.
*/
public function get_sqlite_version(): string {
return $this->mysql_on_sqlite_driver->get_sqlite_version();
}
/**
* Get the SQLite driver version saved in the database.
*
* The saved driver version corresponds to the latest version of the SQLite
* driver that was used to initialize and configure the SQLite database.
*
* @return string SQLite driver version as a string.
* @throws PDOException When the query execution fails.
*/
public function get_saved_driver_version(): string {
return $this->mysql_on_sqlite_driver->get_saved_driver_version();
}
/**
* Check if a specific SQL mode is active.
*
* @param string $mode The SQL mode to check.
* @return bool True if the SQL mode is active, false otherwise.
*/
public function is_sql_mode_active( string $mode ): bool {
return $this->mysql_on_sqlite_driver->is_sql_mode_active( $mode );
}
/**
* Get the last executed MySQL query.
*
* @return string|null
*/
public function get_last_mysql_query(): ?string {
return $this->mysql_on_sqlite_driver->get_last_mysql_query();
}
/**
* Get SQLite queries executed for the last MySQL query.
*
* @return array{ sql: string, params: array }[]
*/
public function get_last_sqlite_queries(): array {
return $this->mysql_on_sqlite_driver->get_last_sqlite_queries();
}
/**
* Get the auto-increment value generated for the last query.
*
* @return int|string
*/
public function get_insert_id() {
return $this->mysql_on_sqlite_driver->get_insert_id();
}
/**
* @param string $query Full SQL statement string.
* @param int $fetch_mode PDO fetch mode. Default is PDO::FETCH_OBJ.
* @param array ...$fetch_mode_args Additional fetch mode arguments.
*
* @return mixed Return value, depending on the query type.
*
* @throws WP_SQLite_Driver_Exception When the query execution fails.
*/
public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) {
$stmt = $this->mysql_on_sqlite_driver->query( $query, $fetch_mode, ...$fetch_mode_args );
if ( $stmt->columnCount() > 0 ) {
$this->last_result = $stmt->fetchAll( $fetch_mode );
} else {
$this->last_result = $stmt->rowCount();
}
return $this->last_result;
}
/**
* Tokenize a MySQL query and initialize a parser.
*
* @param string $query The MySQL query to parse.
* @return WP_MySQL_Parser A parser initialized for the MySQL query.
*/
public function create_parser( string $query ): WP_MySQL_Parser {
return $this->mysql_on_sqlite_driver->create_parser( $query );
}
/**
* Get results of the last query.
*
* @return mixed
*/
public function get_query_results() {
return $this->last_result;
}
/**
* Get return value of the last query() function call.
*
* @return mixed
*/
public function get_last_return_value() {
return $this->last_result;
}
/**
* Get the number of columns returned by the last emulated query.
*
* @return int
*/
public function get_last_column_count(): int {
return $this->mysql_on_sqlite_driver->get_last_column_count();
}
/**
* Get column metadata for results of the last emulated query.
*
* @return array
*/
public function get_last_column_meta(): array {
return $this->mysql_on_sqlite_driver->get_last_column_meta();
}
/**
* Execute a query in SQLite.
*
* @param string $sql The query to execute.
* @param array $params The query parameters.
* @throws PDOException When the query execution fails.
* @return PDOStatement The PDO statement object.
*/
public function execute_sqlite_query( string $sql, array $params = array() ): PDOStatement {
return $this->mysql_on_sqlite_driver->execute_sqlite_query( $sql, $params );
}
/**
* Begin a new transaction or nested transaction.
*/
public function beginTransaction(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
$this->mysql_on_sqlite_driver->begin_transaction();
}
/**
* A temporary alias for back compatibility.
*
* @see self::beginTransaction()
*/
public function begin_transaction(): void {
$this->beginTransaction();
}
/**
* Commit the current transaction or nested transaction.
*/
public function commit(): void {
$this->mysql_on_sqlite_driver->commit();
}
/**
* Rollback the current transaction or nested transaction.
*/
public function rollback(): void {
$this->mysql_on_sqlite_driver->rollback();
}
/**
* Proxy also the private property "$main_db_name", as it is used in tests.
*/
public function __set( string $name, $value ): void {
if ( 'main_db_name' === $name ) {
$closure = function ( string $value ) {
$this->main_db_name = $value;
};
$closure->call( $this->mysql_on_sqlite_driver, $value );
}
}
/**
* Proxy also this private method, as it is used in tests.
*/
private function quote_mysql_utf8_string_literal( string $utf8_literal ): string {
$closure = function ( string $utf8_literal ) {
return $this->quote_mysql_utf8_string_literal( $utf8_literal );
};
return $closure->call( $this->mysql_on_sqlite_driver, $utf8_literal );
}
}
@@ -0,0 +1,152 @@
<?php
/**
* SQLite information schema exception.
*
* This class is used to represent errors that may occur when building
* the MySQL information schema for emulation in SQLite.
*/
class WP_SQLite_Information_Schema_Exception extends Exception {
// Information schema exception types.
const TYPE_DUPLICATE_TABLE_NAME = 'duplicate-table-name';
const TYPE_DUPLICATE_COLUMN_NAME = 'duplicate-column-name';
const TYPE_DUPLICATE_KEY_NAME = 'duplicate-key-name';
const TYPE_KEY_COLUMN_NOT_FOUND = 'key-column-not-found';
const TYPE_CONSTRAINT_DOES_NOT_EXIST = 'constraint-does-not-exist';
const TYPE_MULTIPLE_CONSTRAINTS_WITH_NAME = 'multiple-constraints-with-name';
/**
* The exception type.
*
* @var string
*/
private $type;
/**
* The data to be passed with the exception.
*
* @var array
*/
private $data;
/**
* Constructor.
*
* @param string $type The exception type.
* @param string $message The exception message.
* @param array $data The data to be passed with the exception.
* @param Throwable|null $previous The previous throwable used for the exception chaining.
*/
public function __construct(
string $type,
string $message,
array $data = array(),
?Throwable $previous = null
) {
parent::__construct( $message, 0, $previous );
$this->type = $type;
$this->data = $data;
}
/**
* Get the type of the exception.
*
* @return string The type of the exception.
*/
public function get_type(): string {
return $this->type;
}
/**
* Get the data associated with the exception.
*
* @return array The data associated with the exception.
*/
public function get_data(): array {
return $this->data;
}
/**
* Create a duplicate table name exception.
*
* @param string $table_name The name of the affected table.
* @return self The exception instance.
*/
public static function duplicate_table_name( string $table_name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_DUPLICATE_TABLE_NAME,
sprintf( "Table '%s' already exists.", $table_name ),
array( 'table_name' => $table_name )
);
}
/**
* Create a duplicate column name exception.
*
* @param string $column_name The name of the affected column.
* @return self The exception instance.
*/
public static function duplicate_column_name( string $column_name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_DUPLICATE_COLUMN_NAME,
sprintf( "Column '%s' already exists.", $column_name ),
array( 'column_name' => $column_name )
);
}
/**
* Create a duplicate key name exception.
*
* @param string $key_name The name of the affected key.
* @return self The exception instance.
*/
public static function duplicate_key_name( string $key_name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_DUPLICATE_KEY_NAME,
sprintf( "Key '%s' already exists.", $key_name ),
array( 'key_name' => $key_name )
);
}
/**
* Create a key column not found exception.
*
* @param string $column_name The name of the affected column.
* @return self The exception instance.
*/
public static function key_column_not_found( string $column_name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_KEY_COLUMN_NOT_FOUND,
sprintf( "Key column '%s' doesn't exist in table.", $column_name ),
array( 'column_name' => $column_name )
);
}
/**
* Create a constraint does not exist exception.
*
* @param string $name The name of the affected constraint.
* @return self The exception instance.
*/
public static function constraint_does_not_exist( string $name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_CONSTRAINT_DOES_NOT_EXIST,
sprintf( "Constraint '%s' does not exist.", $name ),
array( 'name' => $name )
);
}
/**
* Create a multiple constraints with name exception.
*
* @param string $name The name of the affected constraint.
* @return self The exception instance.
*/
public static function multiple_constraints_with_name( string $name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_MULTIPLE_CONSTRAINTS_WITH_NAME,
sprintf( "Table has multiple constraints with the name '%s'. Please use constraint specific 'DROP' clause.", $name ),
array( 'name' => $name )
);
}
}
@@ -0,0 +1,802 @@
<?php
/*
* The SQLite driver uses PDO. Enable PDO function calls:
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
*/
/**
* SQLite information schema recconstructor for MySQL.
*
* This class checks and reconstructs the MySQL INFORMATION_SCHEMA data in SQLite
* when it becomes out of sync with the actual SQLite database schema.
*
* Currently, it reconstructs schema infromation for missing tables, and removes
* stale data for tables that no longer exist. When used with WordPress, it uses
* the "wp_get_db_schema()" function to reconstruct WordPress table information.
*/
class WP_SQLite_Information_Schema_Reconstructor {
/**
* The SQLite driver instance.
*
* @var WP_PDO_MySQL_On_SQLite
*/
private $driver;
/**
* An instance of the SQLite connection.
*
* @var WP_SQLite_Connection
*/
private $connection;
/**
* A service for managing MySQL INFORMATION_SCHEMA tables in SQLite.
*
* @var WP_SQLite_Information_Schema_Builder
*/
private $schema_builder;
/**
* Constructor.
*
* @param WP_PDO_MySQL_On_SQLite $driver The SQLite driver instance.
* @param WP_SQLite_Information_Schema_Builder $schema_builder The information schema builder instance.
*/
public function __construct(
$driver,
WP_SQLite_Information_Schema_Builder $schema_builder
) {
$this->driver = $driver;
$this->connection = $driver->get_connection();
$this->schema_builder = $schema_builder;
}
/**
* Ensure that the MySQL INFORMATION_SCHEMA data in SQLite is correct.
*
* This method checks if the MySQL INFORMATION_SCHEMA data in SQLite is correct,
* and if it is not, it will reconstruct missing data and remove stale values.
*/
public function ensure_correct_information_schema(): void {
$sqlite_tables = $this->get_sqlite_table_names();
$information_schema_tables = $this->get_information_schema_table_names();
// In WordPress, use "wp_get_db_schema()" to reconstruct WordPress tables.
$wp_tables = $this->get_wp_create_table_statements();
// Reconstruct information schema records for tables that don't have them.
foreach ( $sqlite_tables as $table ) {
if ( ! in_array( $table, $information_schema_tables, true ) ) {
if ( isset( $wp_tables[ $table ] ) ) {
// WordPress core table (as returned by "wp_get_db_schema()").
$ast = $wp_tables[ $table ];
} else {
// Other table (a WordPress plugin or unrelated to WordPress).
$sql = $this->generate_create_table_statement( $table );
$ast = $this->driver->create_parser( $sql )->parse();
if ( null === $ast ) {
throw new WP_SQLite_Driver_Exception( $this->driver, 'Failed to parse the MySQL query.' );
}
}
/*
* First, let's make sure we clean up all related data. This fixes
* partial data corruption, such as when a table record is missing,
* but some related column, index, or constraint records are stored.
*/
$this->record_drop_table( $table );
$this->schema_builder->record_create_table( $ast );
}
}
// Remove information schema records for tables that don't exist.
foreach ( $information_schema_tables as $table ) {
if ( ! in_array( $table, $sqlite_tables, true ) ) {
$this->record_drop_table( $table );
}
}
}
/**
* Record a DROP TABLE statement in the information schema.
*
* This removes a table record from the information schema, as well as all
* column, index, and constraint records that are related to the table.
*
* @param string $table_name The name of the table to drop.
*/
private function record_drop_table( string $table_name ): void {
$sql = sprintf( 'DROP TABLE %s', $this->connection->quote_identifier( $table_name ) ); // TODO: mysql quote
$ast = $this->driver->create_parser( $sql )->parse();
if ( null === $ast ) {
throw new WP_SQLite_Driver_Exception( $this->driver, 'Failed to parse the MySQL query.' );
}
$this->schema_builder->record_drop_table(
$ast->get_first_descendant_node( 'dropStatement' )
);
}
/**
* Get the names of all existing tables in the SQLite database.
*
* @return string[] The names of tables in the SQLite database.
*/
private function get_sqlite_table_names(): array {
return $this->driver->execute_sqlite_query(
"
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name != ?
AND name NOT LIKE ? ESCAPE '\'
AND name NOT LIKE ? ESCAPE '\'
ORDER BY name
",
array(
'_mysql_data_types_cache',
'sqlite\_%',
str_replace( '_', '\_', WP_PDO_MySQL_On_SQLite::RESERVED_PREFIX ) . '%',
)
)->fetchAll( PDO::FETCH_COLUMN );
}
/**
* Get the names of all tables recorded in the information schema.
*
* @return string[] The names of tables in the information schema.
*/
private function get_information_schema_table_names(): array {
$tables_table = $this->schema_builder->get_table_name( false, 'tables' );
return $this->driver->execute_sqlite_query(
sprintf(
'SELECT table_name FROM %s ORDER BY table_name',
$this->connection->quote_identifier( $tables_table )
)
)->fetchAll( PDO::FETCH_COLUMN );
}
/**
* Get a map of parsed CREATE TABLE statements for WordPress tables.
*
* When reconstructing the information schema data for WordPress tables, we
* can use the "wp_get_db_schema()" function to get accurate CREATE TABLE
* statements. This method parses the result of "wp_get_db_schema()" into
* an array of parsed CREATE TABLE statements indexed by the table names.
*
* @return array<string, WP_Parser_Node> The WordPress CREATE TABLE statements.
*/
private function get_wp_create_table_statements(): array {
// Bail out when not in a WordPress environment.
if ( ! defined( 'ABSPATH' ) ) {
return array();
}
/*
* In WP CLI, $wpdb may not be set. In that case, we can't load the schema.
* We need to bail out and use the standard non-WordPress-specific behavior.
*/
global $wpdb;
if ( ! isset( $wpdb ) ) {
// Outside of WP CLI, let's trigger a warning.
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
trigger_error( 'The $wpdb global is not initialized.', E_USER_WARNING );
}
return array();
}
// Ensure the "wp_get_db_schema()" function is defined.
if ( file_exists( ABSPATH . 'wp-admin/includes/schema.php' ) ) {
require_once ABSPATH . 'wp-admin/includes/schema.php';
}
if ( ! function_exists( 'wp_get_db_schema' ) ) {
throw new Exception( 'The "wp_get_db_schema()" function was not defined.' );
}
/*
* At this point, WPDB may not yet be initialized, as we're configuring
* the database connection. Let's only populate the table names using
* the "$table_prefix" global so we can get correct table names.
*/
global $table_prefix;
$wpdb->set_prefix( $table_prefix );
// Get schema for global tables.
$schema = wp_get_db_schema( 'global' );
// For multisite installs, add schema definitions for all sites.
if ( is_multisite() ) {
/*
* We need to use a database query over the "get_sites()" function,
* as WPDB may not yet initialized. Moreover, we need to get the IDs
* of all existing blogs, independent of any filters and actions that
* could possibly alter the results of a "get_sites()" call.
*/
$blog_ids = $this->driver->execute_sqlite_query(
sprintf(
'SELECT blog_id FROM %s',
$this->connection->quote_identifier( $wpdb->blogs )
)
)->fetchAll( PDO::FETCH_COLUMN );
foreach ( $blog_ids as $blog_id ) {
$schema .= wp_get_db_schema( 'blog', (int) $blog_id );
}
} else {
// For single site installs, add schema for the main site.
$schema .= wp_get_db_schema( 'blog' );
}
// Parse the schema.
$parser = $this->driver->create_parser( $schema );
$wp_tables = array();
while ( $parser->next_query() ) {
$ast = $parser->get_query_ast();
if ( null === $ast ) {
throw new WP_SQLite_Driver_Exception( $this->driver, 'Failed to parse the MySQL query.' );
}
$create_node = $ast->get_first_descendant_node( 'createStatement' );
if ( $create_node && $create_node->has_child_node( 'createTable' ) ) {
$name_node = $create_node->get_first_descendant_node( 'tableName' );
$name = $this->unquote_mysql_identifier(
substr( $schema, $name_node->get_start(), $name_node->get_length() )
);
$wp_tables[ $name ] = $create_node;
}
}
return $wp_tables;
}
/**
* Generate a MySQL CREATE TABLE statement from an SQLite table definition.
*
* @param string $table_name The name of the table.
* @return string The CREATE TABLE statement.
*/
private function generate_create_table_statement( string $table_name ): string {
// Columns.
$columns = $this->driver->execute_sqlite_query(
sprintf(
'PRAGMA table_xinfo(%s)',
$this->connection->quote_identifier( $table_name )
)
)->fetchAll( PDO::FETCH_ASSOC );
$definitions = array();
$column_types = array();
foreach ( $columns as $column ) {
$mysql_type = $this->get_cached_mysql_data_type( $table_name, $column['name'] );
if ( null === $mysql_type ) {
$mysql_type = $this->get_mysql_column_type( $column['type'] );
}
$definitions[] = $this->generate_column_definition( $table_name, $column );
$column_types[ $column['name'] ] = $mysql_type;
}
// Primary key.
$pk_columns = array();
foreach ( $columns as $column ) {
// A position of the column in the primary key, starting from index 1.
// A value of 0 means that the column is not part of the primary key.
$pk_position = (int) $column['pk'];
if ( 0 !== $pk_position ) {
$pk_columns[ $pk_position ] = $column['name'];
}
}
// Sort the columns by their position in the primary key.
ksort( $pk_columns );
if ( count( $pk_columns ) > 0 ) {
$quoted_pk_columns = array();
foreach ( $pk_columns as $pk_column ) {
$quoted_pk_columns[] = $this->connection->quote_identifier( $pk_column );
}
$definitions[] = sprintf( 'PRIMARY KEY (%s)', implode( ', ', $quoted_pk_columns ) );
}
// Indexes and keys.
$keys = $this->driver->execute_sqlite_query(
sprintf(
'PRAGMA index_list(%s)',
$this->connection->quote_identifier( $table_name )
)
)->fetchAll( PDO::FETCH_ASSOC );
foreach ( $keys as $key ) {
// Skip the internal index that SQLite may create for a primary key.
// In MySQL, no explicit index needs to be defined for a primary key.
if ( 'pk' === $key['origin'] ) {
continue;
}
$definitions[] = $this->generate_key_definition( $table_name, $key, $column_types );
}
return sprintf(
"CREATE TABLE %s (\n %s\n)",
$this->connection->quote_identifier( $table_name ),
implode( ",\n ", $definitions )
);
}
/**
* Generate a MySQL column definition from an SQLite column information.
*
* This method generates a MySQL column definition from SQLite column data.
*
* @param string $table_name The name of the table.
* @param array $column_info The SQLite column information.
* @return string The MySQL column definition.
*/
private function generate_column_definition( string $table_name, array $column_info ): string {
$definition = array();
$definition[] = $this->connection->quote_identifier( $column_info['name'] );
// Data type.
$mysql_type = $this->get_cached_mysql_data_type( $table_name, $column_info['name'] );
if ( null === $mysql_type ) {
$mysql_type = $this->get_mysql_column_type( $column_info['type'] );
}
/**
* Correct some column types based on their default values:
* 1. In MySQL, non-datetime columns can't have a timestamp default.
* Let's use DATETIME when default is set to CURRENT_TIMESTAMP.
* 2. In MySQL, TEXT and BLOB columns can't have a default value.
* Let's use VARCHAR(65535) and VARBINARY(65535) when default is set.
*/
$default = $this->generate_column_default( $mysql_type, $column_info['dflt_value'] );
if ( 'CURRENT_TIMESTAMP' === $default ) {
$mysql_type = 'datetime';
} elseif ( 'text' === $mysql_type && null !== $default ) {
$mysql_type = 'varchar(65535)';
} elseif ( 'blob' === $mysql_type && null !== $default ) {
$mysql_type = 'varbinary(65535)';
}
$definition[] = $mysql_type;
// NULL/NOT NULL.
if ( '1' === $column_info['notnull'] ) {
$definition[] = 'NOT NULL';
}
// Auto increment.
$is_auto_increment = false;
if ( '0' !== $column_info['pk'] ) {
$is_auto_increment = $this->driver->execute_sqlite_query(
'SELECT 1 FROM sqlite_master WHERE tbl_name = ? AND sql LIKE ?',
array( $table_name, '%AUTOINCREMENT%' )
)->fetchColumn();
if ( $is_auto_increment ) {
$definition[] = 'AUTO_INCREMENT';
}
}
// Default value.
if ( null !== $default && ! $is_auto_increment ) {
$definition[] = 'DEFAULT ' . $default;
}
return implode( ' ', $definition );
}
/**
* Generate a MySQL key definition from an SQLite key information.
*
* This method generates a MySQL key definition from SQLite key data.
*
* @param string $table_name The name of the table.
* @param array $key_info The SQLite key information.
* @param array $column_types The MySQL data types of the columns.
* @return string The MySQL key definition.
*/
private function generate_key_definition( string $table_name, array $key_info, array $column_types ): string {
$definition = array();
// Key type.
$cached_type = $this->get_cached_mysql_data_type( $table_name, $key_info['name'] );
if ( 'FULLTEXT' === $cached_type ) {
$definition[] = 'FULLTEXT KEY';
} elseif ( 'SPATIAL' === $cached_type ) {
$definition[] = 'SPATIAL KEY';
} elseif ( 'UNIQUE' === $cached_type || '1' === $key_info['unique'] ) {
$definition[] = 'UNIQUE KEY';
} else {
$definition[] = 'KEY';
}
// Key name.
$name = $key_info['name'];
/*
* The SQLite driver prefixes index names with "{$table_name}__" to avoid
* naming conflicts among tables in SQLite. We need to remove the prefix.
*/
if ( str_starts_with( $name, "{$table_name}__" ) ) {
$name = substr( $name, strlen( "{$table_name}__" ) );
}
/**
* SQLite creates automatic internal indexes for primary and unique keys,
* naming them in format "sqlite_autoindex_{$table_name}_{$index_id}".
* For these internal indexes, we need to skip their name, so that in
* the generated MySQL definition, they follow implicit MySQL naming.
*/
if ( ! str_starts_with( $name, 'sqlite_autoindex_' ) ) {
$definition[] = $this->connection->quote_identifier( $name );
}
// Key columns.
$key_columns = $this->driver->execute_sqlite_query(
sprintf(
'PRAGMA index_info(%s)',
$this->connection->quote_identifier( $key_info['name'] )
)
)->fetchAll( PDO::FETCH_ASSOC );
$cols = array();
foreach ( $key_columns as $column ) {
/*
* Extract type and length from column data type definition.
*
* This is required when the column data type is inferred from the
* '_mysql_data_types_cache' table, which stores the data type in
* the format "type(length)", such as "varchar(255)".
*/
$max_prefix_length = 100;
$type = strtolower( $column_types[ $column['name'] ] );
$parts = explode( '(', $type );
$column_type = $parts[0];
$column_length = isset( $parts[1] ) ? (int) $parts[1] : null;
/*
* Add an index column prefix length, if needed.
*
* This is required for "text" and "blob" types for columns inferred
* directly from the SQLite schema, and for the following types for
* columns inferred from the '_mysql_data_types_cache' table:
* char, varchar
* text, tinytext, mediumtext, longtext
* blob, tinyblob, mediumblob, longblob
* varbinary
*/
if (
str_ends_with( $column_type, 'char' )
|| str_ends_with( $column_type, 'text' )
|| str_ends_with( $column_type, 'blob' )
|| str_starts_with( $column_type, 'var' )
) {
$cols[] = sprintf(
'%s(%d)',
$this->connection->quote_identifier( $column['name'] ),
min( $column_length ?? $max_prefix_length, $max_prefix_length )
);
} else {
$cols[] = $this->connection->quote_identifier( $column['name'] );
}
}
$definition[] = '(' . implode( ', ', $cols ) . ')';
return implode( ' ', $definition );
}
/**
* Generate a MySQL default value from an SQLite default value.
*
* @param string $mysql_type The MySQL data type of the column.
* @param string|null $default_value The default value of the SQLite column.
* @return string|null The default value, or null if the column has no default value.
*/
private function generate_column_default( string $mysql_type, ?string $default_value ): ?string {
if ( null === $default_value || '' === $default_value ) {
return null;
}
$mysql_type = strtolower( $mysql_type );
if ( str_starts_with( $mysql_type, 'bit' ) ) {
// BIT columns are stored as INTEGER in SQLite.
return "b'" . decbin( (int) $default_value ) . "'";
}
/*
* In MySQL, geometry columns can't have a default value.
*
* Geometry columns are saved as TEXT in SQLite, and in an older version
* of the SQLite driver, TEXT columns were assigned a default value of ''.
*/
if ( 'geomcollection' === $mysql_type || 'geometrycollection' === $mysql_type ) {
return null;
}
/*
* In MySQL, date/time columns can't have a default value of ''.
*
* Date/time columns are saved as TEXT in SQLite, and in an older version
* of the SQLite driver, TEXT columns were assigned a default value of ''.
*/
if (
"''" === $default_value
&& in_array( $mysql_type, array( 'datetime', 'date', 'time', 'timestamp', 'year' ), true )
) {
return null;
}
/**
* Convert SQLite default values to MySQL default values.
*
* See:
* - https://www.sqlite.org/syntax/column-constraint.html
* - https://www.sqlite.org/syntax/literal-value.html
* - https://www.sqlite.org/lang_expr.html#literal_values_constants_
*/
// Quoted string literal. E.g.: 'abc', "abc", `abc`
$first_byte = $default_value[0] ?? null;
if ( '"' === $first_byte || "'" === $first_byte || '`' === $first_byte ) {
$value = substr( $default_value, 1, -1 );
$value = str_replace( $first_byte . $first_byte, $first_byte, $value );
return $this->quote_mysql_utf8_string_literal( $value );
}
// Normalize the default value for easier comparison.
$uppercase_default_value = strtoupper( $default_value );
// NULL, TRUE, FALSE.
if ( 'NULL' === $uppercase_default_value ) {
// DEFAULT NULL is the same as no default value.
return null;
} elseif ( 'TRUE' === $uppercase_default_value ) {
return '1';
} elseif ( 'FALSE' === $uppercase_default_value ) {
return '0';
}
// Date/time values.
if ( 'CURRENT_TIMESTAMP' === $uppercase_default_value ) {
return 'CURRENT_TIMESTAMP';
} elseif ( 'CURRENT_DATE' === $uppercase_default_value ) {
return null; // Not supported in MySQL.
} elseif ( 'CURRENT_TIME' === $uppercase_default_value ) {
return null; // Not supported in MySQL.
}
// SQLite supports underscores in all numeric literals.
$no_underscore_default_value = str_replace( '_', '', $default_value );
// Numeric literals. E.g.: 123, 1.23, -1.23, 1e3, 1.2e-3
if ( is_numeric( $no_underscore_default_value ) ) {
return $no_underscore_default_value;
}
// HEX literals (numeric). E.g.: 0x1a2f, 0X1A2F
$value = filter_var( $no_underscore_default_value, FILTER_VALIDATE_INT, FILTER_FLAG_ALLOW_HEX );
if ( false !== $value ) {
return $value;
}
// BLOB literals (string). E.g.: x'1a2f', X'1A2F'
// Checking the prefix is enough as SQLite doesn't allow malformed values.
if ( str_starts_with( $uppercase_default_value, "X'" ) ) {
// Convert the hex string to ASCII bytes.
return "'" . pack( 'H*', substr( $default_value, 2, -1 ) ) . "'";
}
// Unquoted string literal. E.g.: abc
return $this->quote_mysql_utf8_string_literal( $default_value );
}
/**
* Get a MySQL column or index data type from legacy data types cache table.
*
* This method retrieves MySQL column or index data types from a special table
* that was used by an old version of the SQLite driver and that is otherwise
* no longer needed. This is more precise than direct inference from SQLite.
*
* For columns, it returns full column type, including prefix length, e.g.:
* int(11), bigint(20) unsigned, varchar(255), longtext
*
* For indexes, it returns one of:
* KEY, PRIMARY, UNIQUE, FULLTEXT, SPATIAL
*
* @param string $table_name The table name.
* @param string $column_or_index_name The column or index name.
* @return string|null The MySQL definition, or null when not found.
*/
private function get_cached_mysql_data_type( string $table_name, string $column_or_index_name ): ?string {
try {
$mysql_type = $this->driver->execute_sqlite_query(
'SELECT mysql_type FROM _mysql_data_types_cache
WHERE `table` = ? COLLATE NOCASE
AND (
-- The old SQLite driver stored the MySQL data types in multiple
-- formats - lowercase, uppercase, and, sometimes, with backticks.
column_or_index = ? COLLATE NOCASE
OR column_or_index = ? COLLATE NOCASE
)',
array( $table_name, $column_or_index_name, "`$column_or_index_name`" )
)->fetchColumn();
} catch ( PDOException $e ) {
if ( str_contains( $e->getMessage(), 'no such table' ) ) {
return null;
}
throw $e;
}
if ( false === $mysql_type ) {
return null;
}
/**
* Check whether the stored type value is a valid MySQL column type.
*
* Some older versions of the legacy SQLite driver might have stored
* invalid MySQL column types in some scenarios:
*
* 1. Before https://github.com/WordPress/sqlite-database-integration/pull/126,
* the legacy SQLite driver incorrectly stored MySQL column types
* for columns with multiple type arguments.
*
* E.g., a column definition like "col_name decimal(26, 8)" would
* be stored with invalid type "decimal(26,".
*
* 2. Before https://github.com/WordPress/sqlite-database-integration/commit/b5a9fbaed4d0d843f792aaa959e3d00f193ff1ee
* (see also https://github.com/Automattic/sqlite-database-integration/pull/2),
* the legacy SQLite driver incorrectly recognized indexes on columns
* with type keywords as additional table column definitions.
*
* E.g., an index definition like "KEY timestamp (timestamp)" would
* be stored as column "KEY" with invalid type "timestamp(timestamp)".
*
* To address these issues, we need to check whether the stored type looks
* like a valid MySQL column type definition.
*/
$open_par_index = strpos( $mysql_type, '(' );
$close_par_index = strpos( $mysql_type, ')' );
if ( false !== $open_par_index ) {
$end = false !== $close_par_index ? $close_par_index : strlen( $mysql_type );
$parts = explode( '(', substr( $mysql_type, 0, $end ) );
$type = strtolower( trim( $parts[0] ) );
$args = array();
foreach ( explode( ',', $parts[1] ) as $arg ) {
$args[] = strtolower( trim( $arg ) );
}
// WooCommerce uses decimal(26,8), decimal(19,4), and decimal(3,2)
// column types, so we can can fix the invalid column definitions.
$looks_like_wc_table = str_contains( $table_name, 'wc_' ) || str_contains( $table_name, 'woocommerce_' );
$is_invalid_decimal = 'decimal' === $type && count( $args ) === 2 && '' === $args[1];
if ( $looks_like_wc_table && $is_invalid_decimal ) {
if ( '26' === $args[0] ) {
// Fix "decimal(26,".
return 'decimal(26,8)';
} elseif ( '19' === $args[0] ) {
// Fix "decimal(19,".
return 'decimal(19,4)';
} elseif ( '3' === $args[0] ) {
// Fix "decimal(3,".
return 'decimal(3,2)';
}
}
// Only numeric arguments are allowed for MySQL column types.
// This handles the incorrectly stored index definition case.
foreach ( $args as $arg ) {
if ( ! is_numeric( $arg ) ) {
return null;
}
}
// If there is no closing parenthesis, the type is invalid.
if ( false === $close_par_index ) {
return null;
}
}
// Normalize index type for backward compatibility. Some older versions
// of the SQLite driver stored index types with a " KEY" suffix, e.g.,
// "PRIMARY KEY" or "UNIQUE KEY". More recent versions omit the suffix.
if ( str_ends_with( $mysql_type, ' KEY' ) ) {
$mysql_type = substr( $mysql_type, 0, strlen( $mysql_type ) - strlen( ' KEY' ) );
}
return $mysql_type;
}
/**
* Get a MySQL column type from an SQLite column type.
*
* This method converts an SQLite column type to a MySQL column type as per
* the SQLite column type affinity rules:
* https://sqlite.org/datatype3.html#determination_of_column_affinity
*
* @param string $column_type The SQLite column type.
* @return string The MySQL column type.
*/
private function get_mysql_column_type( string $column_type ): string {
$type = strtoupper( $column_type );
/*
* Following the rules of column affinity:
* https://sqlite.org/datatype3.html#determination_of_column_affinity
*/
// 1. If the declared type contains the string "INT" then it is assigned
// INTEGER affinity.
if ( str_contains( $type, 'INT' ) ) {
return 'int';
}
// 2. If the declared type of the column contains any of the strings
// "CHAR", "CLOB", or "TEXT" then that column has TEXT affinity.
if ( str_contains( $type, 'TEXT' ) || str_contains( $type, 'CHAR' ) || str_contains( $type, 'CLOB' ) ) {
return 'text';
}
// 3. If the declared type for a column contains the string "BLOB" or
// if no type is specified then the column has affinity BLOB.
if ( str_contains( $type, 'BLOB' ) || '' === $type ) {
return 'blob';
}
// 4. If the declared type for a column contains any of the strings
// "REAL", "FLOA", or "DOUB" then the column has REAL affinity.
if ( str_contains( $type, 'REAL' ) || str_contains( $type, 'FLOA' ) ) {
return 'float';
}
if ( str_contains( $type, 'DOUB' ) ) {
return 'double';
}
/**
* 5. Otherwise, the affinity is NUMERIC.
*
* While SQLite defaults to a NUMERIC column affinity, it's better to use
* TEXT in this case, because numeric SQLite columns in non-strict tables
* can contain any text data as well, when it is not a well-formed number.
*
* See: https://sqlite.org/datatype3.html#type_affinity
*/
return 'text';
}
/**
* Format a MySQL UTF-8 string literal for output in a CREATE TABLE statement.
*
* See WP_PDO_MySQL_On_SQLite::quote_mysql_utf8_string_literal().
*
* TODO: This is a copy of WP_PDO_MySQL_On_SQLite::quote_mysql_utf8_string_literal().
* We may consider extracing it to reusable MySQL helpers.
*
* @param string $utf8_literal The UTF-8 string literal to escape.
* @return string The escaped string literal.
*/
private function quote_mysql_utf8_string_literal( string $utf8_literal ): string {
$backslash = chr( 92 );
$replacements = array(
"'" => "''", // A single quote character (').
$backslash => $backslash . $backslash, // A backslash character (\).
chr( 0 ) => $backslash . '0', // An ASCII NULL character (\0).
chr( 10 ) => $backslash . 'n', // A newline (linefeed) character (\n).
chr( 13 ) => $backslash . 'r', // A carriage return character (\r).
);
return "'" . strtr( $utf8_literal, $replacements ) . "'";
}
/**
* Unquote a quoted MySQL identifier.
*
* Remove bounding quotes and replace escaped quotes with their values.
*
* @param string $quoted_identifier The quoted identifier value.
* @return string The unquoted identifier value.
*/
private function unquote_mysql_identifier( string $quoted_identifier ): string {
$first_byte = $quoted_identifier[0] ?? null;
if ( '"' === $first_byte || '`' === $first_byte ) {
$unquoted = substr( $quoted_identifier, 1, -1 );
return str_replace( $first_byte . $first_byte, $first_byte, $unquoted );
}
return $quoted_identifier;
}
}
@@ -0,0 +1,987 @@
<?php
/**
* Custom functions for the SQLite implementation.
*
* @package wp-sqlite-integration
* @since 1.0.0
*/
/**
* This class defines user defined functions(UDFs) for PDO library.
*
* These functions replace those used in the SQL statement with the PHP functions.
*
* Usage:
*
* <code>
* new WP_SQLite_PDO_User_Defined_Functions(ref_to_pdo_obj);
* </code>
*
* This automatically enables ref_to_pdo_obj to replace the function in the SQL statement
* to the ones defined here.
*/
class WP_SQLite_PDO_User_Defined_Functions {
/**
* Registers the user defined functions for SQLite to a PDO instance.
* The functions are registered using PDO::sqliteCreateFunction().
*
* @param PDO|PDO\SQLite $pdo The PDO object.
*/
public static function register_for( $pdo ): self {
$instance = new self();
foreach ( $instance->functions as $f => $t ) {
if ( $pdo instanceof PDO\SQLite ) {
$pdo->createFunction( $f, array( $instance, $t ) );
} else {
$pdo->sqliteCreateFunction( $f, array( $instance, $t ) );
}
}
return $instance;
}
/**
* Array to define MySQL function => function defined with PHP.
*
* Replaced functions must be public.
*
* @var array
*/
private $functions = array(
'throw' => 'throw',
'month' => 'month',
'monthnum' => 'month',
'year' => 'year',
'day' => 'day',
'hour' => 'hour',
'minute' => 'minute',
'second' => 'second',
'week' => 'week',
'weekday' => 'weekday',
'dayofweek' => 'dayofweek',
'dayofmonth' => 'dayofmonth',
'unix_timestamp' => 'unix_timestamp',
'now' => 'now',
'md5' => 'md5',
'curdate' => 'curdate',
'rand' => 'rand',
'from_unixtime' => 'from_unixtime',
'localtime' => 'now',
'localtimestamp' => 'now',
'isnull' => 'isnull',
'if' => '_if',
'regexp' => 'regexp',
'field' => 'field',
'log' => 'log',
'least' => 'least',
'greatest' => 'greatest',
'get_lock' => 'get_lock',
'release_lock' => 'release_lock',
'ucase' => 'ucase',
'lcase' => 'lcase',
'unhex' => 'unhex',
'from_base64' => 'from_base64',
'to_base64' => 'to_base64',
'inet_ntoa' => 'inet_ntoa',
'inet_aton' => 'inet_aton',
'datediff' => 'datediff',
'locate' => 'locate',
'utc_date' => 'utc_date',
'utc_time' => 'utc_time',
'utc_timestamp' => 'utc_timestamp',
'version' => 'version',
// Internal helper functions.
'_helper_like_to_glob_pattern' => '_helper_like_to_glob_pattern',
);
/**
* First element of the RAND(N) LCG state (the value the output is derived from).
*
* @var int|null
*/
private $rand_seed1 = null;
/**
* Second element of the RAND(N) LCG state (the paired value used in the recurrence).
*
* @var int|null
*/
private $rand_seed2 = null;
/**
* Last seed value passed to RAND(N) in the current statement.
*
* Used to detect whether the rand sequence is advancing with the same seed
* (e.g. "SELECT RAND(3) FROM t"), or reseeding (starting a new sequence).
*
* @var int|null
*/
private $rand_last_seed = null;
/**
* Clear any per-statement state held by the UDFs.
*/
public function flush(): void {
$this->rand_seed1 = null;
$this->rand_seed2 = null;
$this->rand_last_seed = null;
}
/**
* A helper function to throw an error from SQLite expressions.
*
* @param string $message The error message.
*
* @throws Exception The error message.
* @return void
*/
public function throw( $message ): void {
throw new Exception( $message );
}
/**
* Method to return the unix timestamp.
*
* Used without an argument, it returns PHP time() function (total seconds passed
* from '1970-01-01 00:00:00' GMT). Used with the argument, it changes the value
* to the timestamp.
*
* @param string $field Representing the date formatted as '0000-00-00 00:00:00'.
*
* @return number of unsigned integer
*/
public function unix_timestamp( $field = null ) {
return is_null( $field ) ? time() : strtotime( $field );
}
/**
* Method to emulate MySQL FROM_UNIXTIME() function.
*
* @param int $field The unix timestamp.
* @param string $format Indicate the way of formatting(optional).
*
* @return string
*/
public function from_unixtime( $field, $format = null ) {
// Convert to ISO time.
$date = gmdate( 'Y-m-d H:i:s', $field );
return is_null( $format ) ? $date : $this->dateformat( $date, $format );
}
/**
* Method to emulate MySQL NOW() function.
*
* @return string representing current time formatted as '0000-00-00 00:00:00'.
*/
public function now() {
return gmdate( 'Y-m-d H:i:s' );
}
/**
* Method to emulate MySQL CURDATE() function.
*
* @return string representing current time formatted as '0000-00-00'.
*/
public function curdate() {
return gmdate( 'Y-m-d' );
}
/**
* Method to emulate MySQL MD5() function.
*
* @param string $field The string to be hashed.
*
* @return string of the md5 hash value of the argument.
*/
public function md5( $field ) {
return md5( $field );
}
/**
* Method to emulate MySQL's seeded RAND(N) function.
*
* Implements MySQL's deterministic LCG (Linear Congruential Generator),
* producing bit-exact output for a given seed.
*
* Known divergences from MySQL:
*
* 1. In MySQL, RAND(N) behaves differently depending on whether the seed
* is constant expression or varies per invocation:
* - Constant seed (e.g. "SELECT RAND(3) FROM t"):
* LCG is initialized once per statement and advanced for each row.
* - Non-constant seed (e.g. "SELECT RAND(col) FROM t"):
* LCG is initialized for every row with its seed value.
*
* A SQLite UDF cannot tell whether the seed expression is constant, so
* we just compare the seed against its last value. This diverges from
* MySQL in rare cases, and we can consider improving it in the future.
*
* 2. The LCG state is shared across call sites in the same query, so
* "SELECT RAND(1), RAND(1)" yields different results here than in MySQL.
* This is a rare edge case that we can consider improving in the future.
*
* Unseeded RAND() never reaches this function. The AST driver translates it
* directly to a more efficient SQLite-native expression.
*
* @param int|float|string|null $seed Seed value.
*
* @return float A value in [0, 1).
*/
public function rand( $seed ) {
// Requires 64-bit PHP. Seed * 0x10000001 can exceed PHP_INT_MAX on 32-bit.
$max_value = 0x3FFFFFFF;
if ( null === $seed ) {
// MySQL treats NULL seed as 0.
$seed = 0;
} elseif ( ! is_int( $seed ) ) {
/*
* MySQL rounds float values and numeric strings take the same path.
* Reduce the value to a 32-bit range using "fmod" to avoid firing
* the "out-of-range float to int" cast deprecation on PHP 8.1+.
*/
$seed = (int) fmod( round( (float) $seed, 0, PHP_ROUND_HALF_EVEN ), 0x100000000 );
}
// Initialize MySQL's internal 30-bit seeds.
if ( $seed !== $this->rand_last_seed ) {
/*
* MySQL casts to uint32, and the intermediate results wrap at 32-bit
* unsigned boundaries. We emulate this with & 0xFFFFFFFF masks.
*/
$seed_u32 = $seed & 0xFFFFFFFF;
$this->rand_seed1 = ( ( $seed_u32 * 0x10001 + 55555555 ) & 0xFFFFFFFF ) % $max_value;
$this->rand_seed2 = ( ( $seed_u32 * 0x10000001 ) & 0xFFFFFFFF ) % $max_value;
$this->rand_last_seed = $seed;
}
/*
* MySQL's LCG recurrence:
* seed1 = (seed1 * 3 + seed2) % max_value
* seed2 = (seed1 + seed2 + 33) % max_value
*
* Note that seed1 is updated first and the new value is used for seed2.
*/
$this->rand_seed1 = ( $this->rand_seed1 * 3 + $this->rand_seed2 ) % $max_value;
$this->rand_seed2 = ( $this->rand_seed1 + $this->rand_seed2 + 33 ) % $max_value;
return (float) $this->rand_seed1 / (float) $max_value;
}
/**
* Method to emulate MySQL DATEFORMAT() function.
*
* @param string $date Formatted as '0000-00-00' or datetime as '0000-00-00 00:00:00'.
* @param string $format The string format.
*
* @return string formatted according to $format
*/
public function dateformat( $date, $format ) {
$mysql_php_date_formats = array(
'%a' => 'D',
'%b' => 'M',
'%c' => 'n',
'%D' => 'jS',
'%d' => 'd',
'%e' => 'j',
'%H' => 'H',
'%h' => 'h',
'%I' => 'h',
'%i' => 'i',
'%j' => 'z',
'%k' => 'G',
'%l' => 'g',
'%M' => 'F',
'%m' => 'm',
'%p' => 'A',
'%r' => 'h:i:s A',
'%S' => 's',
'%s' => 's',
'%T' => 'H:i:s',
'%U' => 'W',
'%u' => 'W',
'%V' => 'W',
'%v' => 'W',
'%W' => 'l',
'%w' => 'w',
'%X' => 'Y',
'%x' => 'o',
'%Y' => 'Y',
'%y' => 'y',
);
$time = strtotime( $date );
$format = strtr( $format, $mysql_php_date_formats );
return gmdate( $format, $time );
}
/**
* Method to extract the month value from the date.
*
* @param string $field Representing the date formatted as 0000-00-00.
*
* @return string Representing the number of the month between 1 and 12.
*/
public function month( $field ) {
/*
* MySQL returns 0 for MONTH('0000-00-00') and for dates with
* zero month parts like '2020-00-15'. PHP's strtotime() can't
* parse these, so we extract the month directly from the string.
*/
if ( preg_match( '/^\d{4}-(\d{2})/', $field, $matches ) ) {
return intval( $matches[1] );
}
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* n - Numeric representation of a month, without leading zeros.
* 1 through 12
*/
return intval( gmdate( 'n', strtotime( $field ) ) );
}
/**
* Method to extract the year value from the date.
*
* @param string $field Representing the date formatted as 0000-00-00.
*
* @return string Representing the number of the year.
*/
public function year( $field ) {
/*
* MySQL returns 0 for YEAR('0000-00-00'). PHP's strtotime()
* can't parse zero dates, so we extract the year directly.
*/
if ( preg_match( '/^(\d{4})-\d{2}/', $field, $matches ) ) {
return intval( $matches[1] );
}
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* Y - A full numeric representation of a year, 4 digits.
*/
return intval( gmdate( 'Y', strtotime( $field ) ) );
}
/**
* Method to extract the day value from the date.
*
* @param string $field Representing the date formatted as 0000-00-00.
*
* @return string Representing the number of the day of the month from 1 and 31.
*/
public function day( $field ) {
/*
* MySQL returns 0 for DAY('0000-00-00') and for dates with
* zero day parts like '2020-01-00'. PHP's strtotime() can't
* parse these, so we extract the day directly from the string.
*/
if ( preg_match( '/^\d{4}-\d{2}-(\d{2})/', $field, $matches ) ) {
return intval( $matches[1] );
}
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* j - Day of the month without leading zeros.
* 1 to 31.
*/
return intval( gmdate( 'j', strtotime( $field ) ) );
}
/**
* Method to emulate MySQL SECOND() function.
*
* @see https://www.php.net/manual/en/datetime.format.php
*
* @param string $field Representing the time formatted as '00:00:00'.
*
* @return number Unsigned integer
*/
public function second( $field ) {
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* s - Seconds, with leading zeros (00 to 59)
*/
return intval( gmdate( 's', strtotime( $field ) ) );
}
/**
* Method to emulate MySQL MINUTE() function.
*
* @param string $field Representing the time formatted as '00:00:00'.
*
* @return int
*/
public function minute( $field ) {
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* i - Minutes with leading zeros.
* 00 to 59.
*/
return intval( gmdate( 'i', strtotime( $field ) ) );
}
/**
* Method to emulate MySQL HOUR() function.
*
* Returns the hour for time, in 24-hour format, from 0 to 23.
* Importantly, midnight is 0, not 24.
*
* @param string $time Representing the time formatted, like '14:08:12'.
*
* @return int
*/
public function hour( $time ) {
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* H 24-hour format of an hour with leading zeros.
* 00 through 23.
*/
return intval( gmdate( 'H', strtotime( $time ) ) );
}
/**
* Covers MySQL WEEK() function.
*
* Always assumes $mode = 1.
*
* @TODO: Support other modes.
*
* From https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html#function_week:
*
* > Returns the week number for date. The two-argument form of WEEK()
* > enables you to specify whether the week starts on Sunday or Monday
* > and whether the return value should be in the range from 0 to 53
* > or from 1 to 53. If the mode argument is omitted, the value of the
* > default_week_format system variable is used.
* >
* > The following table describes how the mode argument works:
* >
* > Mode First day of week Range Week 1 is the first week …
* > 0 Sunday 0-53 with a Sunday in this year
* > 1 Monday 0-53 with 4 or more days this year
* > 2 Sunday 1-53 with a Sunday in this year
* > 3 Monday 1-53 with 4 or more days this year
* > 4 Sunday 0-53 with 4 or more days this year
* > 5 Monday 0-53 with a Monday in this year
* > 6 Sunday 1-53 with 4 or more days this year
* > 7 Monday 1-53 with a Monday in this year
*
* @param string $field Representing the date.
* @param int $mode The mode argument.
*/
public function week( $field, $mode ) {
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* W - ISO-8601 week number of year, weeks starting on Monday.
* Example: 42 (the 42nd week in the year)
*
* Week 1 is the first week with a Thursday in it.
*/
return intval( gmdate( 'W', strtotime( $field ) ) );
}
/**
* Simulates WEEKDAY() function in MySQL.
*
* Returns the day of the week as an integer.
* The days of the week are numbered 0 to 6:
* * 0 for Monday
* * 1 for Tuesday
* * 2 for Wednesday
* * 3 for Thursday
* * 4 for Friday
* * 5 for Saturday
* * 6 for Sunday
*
* @param string $field Representing the date.
*
* @return int
*/
public function weekday( $field ) {
/*
* date('N') returns 1 (for Monday) through 7 (for Sunday)
* That's one more than MySQL.
* Let's subtract one to make it compatible.
*/
return intval( gmdate( 'N', strtotime( $field ) ) ) - 1;
}
/**
* Method to emulate MySQL DAYOFMONTH() function.
*
* @see https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html#function_dayofmonth
*
* @param string $field Representing the date.
*
* @return int Returns the day of the month for date as a number in the range 1 to 31.
*/
public function dayofmonth( $field ) {
return intval( gmdate( 'j', strtotime( $field ) ) );
}
/**
* Method to emulate MySQL DAYOFWEEK() function.
*
* > Returns the weekday index for date (1 = Sunday, 2 = Monday, …, 7 = Saturday).
* > These index values correspond to the ODBC standard. Returns NULL if date is NULL.
*
* @param string $field Representing the date.
*
* @return int Returns the weekday index for date (1 = Sunday, 2 = Monday, …, 7 = Saturday).
*/
public function dayofweek( $field ) {
/**
* From https://www.php.net/manual/en/datetime.format.php:
*
* `w` Numeric representation of the day of the week
* 0 (for Sunday) through 6 (for Saturday)
*/
return intval( gmdate( 'w', strtotime( $field ) ) ) + 1;
}
/**
* Method to emulate MySQL DATE() function.
*
* @see https://www.php.net/manual/en/datetime.format.php
*
* @param string $date formatted as unix time.
*
* @return string formatted as '0000-00-00'.
*/
public function date( $date ) {
return gmdate( 'Y-m-d', strtotime( $date ) );
}
/**
* Method to emulate MySQL ISNULL() function.
*
* This function returns true if the argument is null, and true if not.
*
* @param mixed $field The field to be tested.
*
* @return boolean
*/
public function isnull( $field ) {
return is_null( $field );
}
/**
* Method to emulate MySQL IF() function.
*
* As 'IF' is a reserved word for PHP, function name must be changed.
*
* @param mixed $expression The statement to be evaluated as true or false.
* @param mixed $truthy Statement or value returned if $expression is true.
* @param mixed $falsy Statement or value returned if $expression is false.
*
* @return mixed
*/
public function _if( $expression, $truthy, $falsy ) {
return ( true === $expression ) ? $truthy : $falsy;
}
/**
* Method to emulate MySQL REGEXP() function.
*
* @param string $pattern Regular expression to match.
* @param string $field Haystack.
*
* @return integer 1 if matched, 0 if not matched.
*/
public function regexp( $pattern, $field ) {
/*
* If the original query says REGEXP BINARY
* the comparison is byte-by-byte and letter casing now
* matters since lower- and upper-case letters have different
* byte codes.
*
* The REGEXP function can't be easily made to accept two
* parameters, so we'll have to use a hack to get around this.
*
* If the first character of the pattern is a null byte, we'll
* remove it and make the comparison case-sensitive. This should
* be reasonably safe since PHP does not allow null bytes in
* regular expressions anyway.
*/
if ( "\x00" === $pattern[0] ) {
$pattern = substr( $pattern, 1 );
$flags = '';
} else {
// Otherwise, the search is case-insensitive.
$flags = 'i';
}
$pattern = str_replace( '/', '\/', $pattern );
$pattern = '/' . $pattern . '/' . $flags;
return preg_match( $pattern, $field );
}
/**
* Method to emulate MySQL FIELD() function.
*
* This function gets the list argument and compares the first item to all the others.
* If the same value is found, it returns the position of that value. If not, it
* returns 0.
*
* @return int
*/
public function field() {
$num_args = func_num_args();
if ( $num_args < 2 || is_null( func_get_arg( 0 ) ) ) {
return 0;
}
$arg_list = func_get_args();
$search_string = strtolower( array_shift( $arg_list ) );
for ( $i = 0; $i < $num_args - 1; $i++ ) {
if ( strtolower( $arg_list[ $i ] ) === $search_string ) {
return $i + 1;
}
}
return 0;
}
/**
* Method to emulate MySQL LOG() function.
*
* Used with one argument, it returns the natural logarithm of X.
* <code>
* LOG(X)
* </code>
* Used with two arguments, it returns the natural logarithm of X base B.
* <code>
* LOG(B, X)
* </code>
* In this case, it returns the value of log(X) / log(B).
*
* Used without an argument, it returns false. This returned value will be
* rewritten to 0, because SQLite doesn't understand true/false value.
*
* @return double|null
*/
public function log() {
$num_args = func_num_args();
if ( 1 === $num_args ) {
$arg1 = func_get_arg( 0 );
return log( $arg1 );
}
if ( 2 === $num_args ) {
$arg1 = func_get_arg( 0 );
$arg2 = func_get_arg( 1 );
return log( $arg1 ) / log( $arg2 );
}
return null;
}
/**
* Method to emulate MySQL LEAST() function.
*
* This function rewrites the function name to SQLite compatible function name.
*
* @return mixed
*/
public function least() {
$arg_list = func_get_args();
return min( $arg_list );
}
/**
* Method to emulate MySQL GREATEST() function.
*
* This function rewrites the function name to SQLite compatible function name.
*
* @return mixed
*/
public function greatest() {
$arg_list = func_get_args();
return max( $arg_list );
}
/**
* Method to dummy out MySQL GET_LOCK() function.
*
* This function is meaningless in SQLite, so we do nothing.
*
* @param string $name Not used.
* @param integer $timeout Not used.
*
* @return string
*/
public function get_lock( $name, $timeout ) {
return '1=1';
}
/**
* Method to dummy out MySQL RELEASE_LOCK() function.
*
* This function is meaningless in SQLite, so we do nothing.
*
* @param string $name Not used.
*
* @return string
*/
public function release_lock( $name ) {
return '1=1';
}
/**
* Method to emulate MySQL UCASE() function.
*
* This is MySQL alias for upper() function. This function rewrites it
* to SQLite compatible name upper().
*
* @param string $content String to be converted to uppercase.
*
* @return string SQLite compatible function name.
*/
public function ucase( $content ) {
return "upper($content)";
}
/**
* Method to emulate MySQL LCASE() function.
*
* This is MySQL alias for lower() function. This function rewrites it
* to SQLite compatible name lower().
*
* @param string $content String to be converted to lowercase.
*
* @return string SQLite compatible function name.
*/
public function lcase( $content ) {
return "lower($content)";
}
/**
* Method to emulate MySQL UNHEX() function.
*
* For a string argument str, UNHEX(str) interprets each pair of characters
* in the argument as a hexadecimal number and converts it to the byte represented
* by the number. The return value is a binary string.
*
* @param string $number Number to be unhexed.
*
* @return string Binary string
*/
public function unhex( $number ) {
return pack( 'H*', $number );
}
/**
* Method to emulate MySQL FROM_BASE64() function.
*
* Takes a base64-encoded string and returns the decoded result as a binary
* string. Returns NULL if the argument is NULL or is not a valid base64 string.
*
* @param string|null $str The base64-encoded string.
*
* @return string|null Decoded binary string, or NULL.
*/
public function from_base64( $str ) {
if ( null === $str ) {
return null;
}
$decoded = base64_decode( $str, true );
if ( false === $decoded ) {
return null;
}
return $decoded;
}
/**
* Method to emulate MySQL TO_BASE64() function.
*
* Takes a string and returns a base64-encoded result.
* Returns NULL if the argument is NULL.
*
* @param string|null $str The string to encode.
*
* @return string|null Base64-encoded string, or NULL.
*/
public function to_base64( $str ) {
if ( null === $str ) {
return null;
}
return base64_encode( $str );
}
/**
* Method to emulate MySQL INET_NTOA() function.
*
* This function gets 4 or 8 bytes integer and turn it into the network address.
*
* @param integer $num Long integer.
*
* @return string
*/
public function inet_ntoa( $num ) {
return long2ip( $num );
}
/**
* Method to emulate MySQL INET_ATON() function.
*
* This function gets the network address and turns it into integer.
*
* @param string $addr Network address.
*
* @return int long integer
*/
public function inet_aton( $addr ) {
return absint( ip2long( $addr ) );
}
/**
* Method to emulate MySQL DATEDIFF() function.
*
* This function compares two dates value and returns the difference.
*
* @param string $start Start date.
* @param string $end End date.
*
* @return string
*/
public function datediff( $start, $end ) {
$start_date = new DateTime( $start );
$end_date = new DateTime( $end );
$interval = $end_date->diff( $start_date, false );
return $interval->format( '%r%a' );
}
/**
* Method to emulate MySQL LOCATE() function.
*
* This function returns the position if $substr is found in $str. If not,
* it returns 0. If mbstring extension is loaded, mb_strpos() function is
* used.
*
* @param string $substr Needle.
* @param string $str Haystack.
* @param integer $pos Position.
*
* @return integer
*/
public function locate( $substr, $str, $pos = 0 ) {
if ( ! extension_loaded( 'mbstring' ) ) {
$val = strpos( $str, $substr, $pos );
if ( false !== $val ) {
return $val + 1;
}
return 0;
}
$val = mb_strpos( $str, $substr, $pos );
if ( false !== $val ) {
return $val + 1;
}
return 0;
}
/**
* Method to return GMT date in the string format.
*
* @return string formatted GMT date 'dddd-mm-dd'
*/
public function utc_date() {
return gmdate( 'Y-m-d', time() );
}
/**
* Method to return GMT time in the string format.
*
* @return string formatted GMT time '00:00:00'
*/
public function utc_time() {
return gmdate( 'H:i:s', time() );
}
/**
* Method to return GMT time stamp in the string format.
*
* @return string formatted GMT timestamp 'yyyy-mm-dd 00:00:00'
*/
public function utc_timestamp() {
return gmdate( 'Y-m-d H:i:s', time() );
}
/**
* Method to return MySQL version.
*
* This function only returns the current newest version number of MySQL,
* because it is meaningless for SQLite database.
*
* @return string representing the version number: major_version.minor_version
*/
public function version() {
return '5.5';
}
/**
* A helper to covert LIKE pattern to a GLOB pattern for "LIKE BINARY" support.
* @TODO: Some of the MySQL string specifics described below are likely to
* affect also other patterns than just "LIKE BINARY". We should
* consider applying some of the conversions more broadly.
*
* @param string $pattern
* @return string
*/
public function _helper_like_to_glob_pattern( $pattern ) {
if ( null === $pattern ) {
return null;
}
/*
* 1. Escape characters that have special meaning in GLOB patterns.
*
* We need to:
* 1. Escape "]" as "[]]" to avoid interpreting "[...]" as a character class.
* 2. Escape "*" as "[*]" (must be after 1 to avoid being escaped).
* 3. Escape "?" as "[?]" (must be after 1 to avoid being escaped).
*/
$pattern = str_replace( ']', '[]]', $pattern );
$pattern = str_replace( '*', '[*]', $pattern );
$pattern = str_replace( '?', '[?]', $pattern );
/*
* 2. Convert LIKE wildcards to GLOB wildcards ("%" -> "*", "_" -> "?").
*
* We need to convert them only when they don't follow any backslashes,
* or when they follow an even number of backslashes (as "\\" is "\").
*/
$pattern = preg_replace( '/(^|[^\\\\](?:\\\\{2})*)%/', '$1*', $pattern );
$pattern = preg_replace( '/(^|[^\\\\](?:\\\\{2})*)_/', '$1?', $pattern );
/*
* 3. Unescape LIKE escape sequences.
*
* While in MySQL LIKE patterns, a backslash is usually used to escape
* special characters ("%", "_", and "\"), it works with all characters.
*
* That is:
* SELECT '\\x' prints '\x', but LIKE '\\x' is equivalent to LIKE 'x'.
*
* This is true also for multi-byte characters:
* SELECT '\\©' prints '\©', but LIKE '\\©' is equivalent to LIKE '©'.
*
* However, the multi-byte behavior is likely to depend on the charset.
* For now, we'll assume UTF-8 and thus the "u" modifier for the regex.
*/
$pattern = preg_replace( '/\\\\(.)/u', '$1', $pattern );
return $pattern;
}
}
@@ -0,0 +1,8 @@
<?php
/**
* The version of the SQLite driver.
*
* This constant needs to be updated on plugin release!
*/
define( 'SQLITE_DRIVER_VERSION', '3.0.0-rc.4' );