diff --git a/composer.json b/composer.json index 689b44ed..ea5db7a4 100644 --- a/composer.json +++ b/composer.json @@ -9,8 +9,10 @@ }, "require": { "php": ">=7.2", - "ext-pdo": "*", - "ext-pdo_sqlite": "*" + "ext-pdo": "*" + }, + "suggest": { + "ext-pdo_sqlite": "Recommended. Without the PDO SQLite driver, the plugin falls back to the bundled (slower) pure-PHP database engine." }, "require-dev": { "ext-mbstring": "*", diff --git a/packages/mysql-on-sqlite/src/load.php b/packages/mysql-on-sqlite/src/load.php index 62387a2e..5038bfb7 100644 --- a/packages/mysql-on-sqlite/src/load.php +++ b/packages/mysql-on-sqlite/src/load.php @@ -33,6 +33,7 @@ } else { require_once __DIR__ . '/mysql/class-wp-mysql-parser.php'; } +require_once __DIR__ . '/php-engine/load.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'; diff --git a/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-evaluator.php b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-evaluator.php new file mode 100644 index 00000000..7adc2340 --- /dev/null +++ b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-evaluator.php @@ -0,0 +1,2175 @@ + string|null, // lowercase alias or table name + * 'cols' => array, // lowercase column name => value + * 'names' => array, // lowercase column name => original name + * 'aff' => array, // lowercase column name => affinity + * 'coll' => array, // lowercase column name => collation + * 'decl' => array, // lowercase column name => declared type + * 'rowid' => int|null, // the rowid, if the source is a table + * ) + */ +class WP_PHP_Engine_Evaluator { + /** + * The engine instance. + * + * @var WP_PHP_Engine + */ + private $engine; + + /** + * Bound parameter values. + * + * @var array + */ + private $params; + + /** + * The scope stack (a list of frames, innermost last). + * + * @var array + */ + public $scopes = array(); + + /** + * Common table expressions visible in the current statement. + * + * A map of lowercase name => array( 'cols' => ?, 'sel' => AST ) or + * array( 'materialized' => result ). + * + * @var array + */ + public $ctes = array(); + + /** + * Constructor. + * + * @param WP_PHP_Engine $engine The engine instance. + * @param array $params The bound parameter values. + */ + public function __construct( $engine, $params = array() ) { + $this->engine = $engine; + $this->params = $params; + } + + /* + * ---------------------------------------------------------------------- + * SELECT pipeline. + * ---------------------------------------------------------------------- + */ + + /** + * Execute a select node and return the result. + * + * @param array $select The select AST node. + * @return array The result: array( 'cols', 'rows', 'decl' ). + */ + public function select( $select ) { + $saved_ctes = $this->ctes; + if ( ! empty( $select['with'] ) ) { + foreach ( $select['with'] as $name => $cte ) { + $this->ctes[ $name ] = $cte; + } + } + + // Evaluate all compound parts. + $results = array(); + foreach ( $select['parts'] as $part ) { + $results[] = $this->select_core( $part ); + } + + $result = $results[0]; + $count = count( $results ); + if ( $count > 1 && isset( $result['srctable'] ) ) { + $result['srctable'] = array_fill( 0, count( $result['cols'] ), null ); + } + for ( $i = 1; $i < $count; $i++ ) { + $op = $select['ops'][ $i - 1 ]; + $other = $results[ $i ]; + if ( count( $other['cols'] ) !== count( $result['cols'] ) ) { + throw new WP_PHP_Engine_SQL_Exception( + 'SELECTs to the left and right of ' . $op . ' do not have the same number of result columns' + ); + } + switch ( $op ) { + case 'UNION ALL': + foreach ( $other['rows'] as $row ) { + $result['rows'][] = $row; + } + $result['frames'] = null; + break; + case 'UNION': + $seen = array(); + foreach ( $result['rows'] as $row ) { + $seen[ self::row_key( $row ) ] = true; + } + $merged = $result['rows']; + foreach ( $other['rows'] as $row ) { + $key = self::row_key( $row ); + if ( ! isset( $seen[ $key ] ) ) { + $seen[ $key ] = true; + $merged[] = $row; + } + } + // UNION also dedupes the left side. + $deduped = array(); + $seen = array(); + foreach ( $merged as $row ) { + $key = self::row_key( $row ); + if ( ! isset( $seen[ $key ] ) ) { + $seen[ $key ] = true; + $deduped[] = $row; + } + } + $result['rows'] = $deduped; + $result['frames'] = null; + break; + case 'EXCEPT': + $exclude = array(); + foreach ( $other['rows'] as $row ) { + $exclude[ self::row_key( $row ) ] = true; + } + $kept = array(); + $seen = array(); + foreach ( $result['rows'] as $row ) { + $key = self::row_key( $row ); + if ( ! isset( $exclude[ $key ] ) && ! isset( $seen[ $key ] ) ) { + $seen[ $key ] = true; + $kept[] = $row; + } + } + $result['rows'] = $kept; + $result['frames'] = null; + break; + case 'INTERSECT': + $include = array(); + foreach ( $other['rows'] as $row ) { + $include[ self::row_key( $row ) ] = true; + } + $kept = array(); + $seen = array(); + foreach ( $result['rows'] as $row ) { + $key = self::row_key( $row ); + if ( isset( $include[ $key ] ) && ! isset( $seen[ $key ] ) ) { + $seen[ $key ] = true; + $kept[] = $row; + } + } + $result['rows'] = $kept; + $result['frames'] = null; + break; + } + } + + // ORDER BY. + if ( ! empty( $select['order'] ) ) { + $this->sort_result( $result, $select['order'] ); + } + + // LIMIT and OFFSET. + if ( null !== $select['limit'] ) { + $limit = $this->eval( $select['limit'], array() ); + $limit = null === $limit ? -1 : (int) $limit; + $offset = 0; + if ( null !== $select['offset'] ) { + $offset = (int) $this->eval( $select['offset'], array() ); + if ( $offset < 0 ) { + $offset = 0; + } + } + if ( $limit >= 0 ) { + $result['rows'] = array_slice( $result['rows'], $offset, $limit ); + } elseif ( $offset > 0 ) { + $result['rows'] = array_slice( $result['rows'], $offset ); + } + } + + unset( $result['frames'], $result['items'], $result['positions'], $result['proto'] ); + $this->ctes = $saved_ctes; + return $result; + } + + /** + * Execute one core SELECT or VALUES part. + * + * @param array $core The core AST node. + * @return array The result: array( 'cols', 'rows', 'decl', 'frames' ). + */ + private function select_core( $core ) { + if ( 'values' === $core['t'] ) { + $rows = array(); + $width = 0; + foreach ( $core['rows'] as $row_exprs ) { + $row = array(); + foreach ( $row_exprs as $expr ) { + $row[] = $this->eval( $expr, array() ); + } + $width = max( $width, count( $row ) ); + $rows[] = $row; + } + $cols = array(); + for ( $i = 1; $i <= $width; $i++ ) { + $cols[] = 'column' . $i; + } + return array( + 'cols' => $cols, + 'rows' => $rows, + 'decl' => array_fill( 0, $width, null ), + 'coll' => array_fill( 0, $width, 'BINARY' ), + 'aliased' => array_fill( 0, $width, true ), + 'frames' => null, + ); + } + + // Resolve FROM into a schema prototype and a list of frames + // (each frame is a list of slots). + if ( null === $core['from'] ) { + $frames = array( array() ); // A single row with no columns. + $proto = array(); + } else { + $resolved = $this->resolve_from( $core['from'] ); + $frames = $resolved['frames']; + $proto = $resolved['proto']; + } + + // SQLite allows result-column aliases in WHERE and HAVING clauses; + // substitute alias references that do not resolve to real columns. + $where_expr = null !== $core['where'] ? $this->substitute_aliases( $core['where'], $core['items'], $proto ) : null; + $having_expr = null !== $core['having'] ? $this->substitute_aliases( $core['having'], $core['items'], $proto ) : null; + + // WHERE. + if ( null !== $where_expr ) { + $filtered = array(); + foreach ( $frames as $frame ) { + if ( WP_PHP_Engine_Values::is_truthy( $this->eval( $where_expr, $frame ) ) ) { + $filtered[] = $frame; + } + } + $frames = $filtered; + } + + // Detect aggregate usage. + $has_aggregates = false; + foreach ( $core['items'] as $item ) { + if ( isset( $item['e'] ) && $this->contains_aggregate( $item['e'] ) ) { + $has_aggregates = true; + break; + } + } + if ( null !== $having_expr && $this->contains_aggregate( $having_expr ) ) { + $has_aggregates = true; + } + + if ( null !== $having_expr && null === $core['group'] ) { + $has_aggregates = true; + } + + $grouped = null; + if ( null !== $core['group'] ) { + $grouped = array(); + foreach ( $frames as $frame ) { + $key_parts = array(); + foreach ( $core['group'] as $group_expr ) { + // Positional GROUP BY (e.g. GROUP BY 1) refers to a select item. + $resolved = $this->resolve_positional_or_alias( $group_expr, $core['items'] ); + $value = $this->eval( $resolved, $frame ); + $collation = $this->expr_collation( $resolved, $frame ); + if ( 'NOCASE' === $collation && is_string( $value ) ) { + $key_parts[] = 't:' . strtolower( $value ); + } else { + $key_parts[] = self::value_key( $value ); + } + } + $key = implode( '|', $key_parts ); + if ( ! isset( $grouped[ $key ] ) ) { + $grouped[ $key ] = array(); + } + $grouped[ $key ][] = $frame; + } + $grouped = array_values( $grouped ); + } elseif ( $has_aggregates ) { + // Implicit single group over all rows. + $grouped = array( $frames ); + } + + // The output schema (column names and declared types) comes from the + // relation prototype, so that it is correct even with zero rows. + list( $cols, $decl, $coll, $item_positions, $aliased_flags, $srctable ) = $this->project_schema( $core['items'], $proto ); + + // SQLite resolves names at prepare time; with no input rows, the + // clauses are never evaluated, so resolve them once explicitly. + if ( 0 === count( $frames ) ) { + if ( null !== $where_expr ) { + $this->eval( $where_expr, $proto ); + } + if ( null !== $core['group'] ) { + foreach ( $core['group'] as $group_expr ) { + $this->eval( $this->resolve_positional_or_alias( $group_expr, $core['items'] ), $proto ); + } + } + if ( null !== $having_expr ) { + $this->eval_in_group( $having_expr, $proto, array() ); + } + } + + $rows = array(); + $row_frames = array(); + + if ( null !== $grouped ) { + foreach ( $grouped as $group ) { + $frame = count( $group ) > 0 ? $group[0] : $proto; + if ( null !== $having_expr ) { + $having = $this->eval_in_group( $having_expr, $frame, $group ); + if ( ! WP_PHP_Engine_Values::is_truthy( $having ) ) { + continue; + } + } + $rows[] = $this->project_row( $core['items'], $frame, $group ); + $row_frames[] = $frame; + } + // An empty implicit group still yields one row of aggregates. + if ( null === $core['group'] && $has_aggregates && 0 === count( $grouped ) ) { + $rows = array( $this->project_row( $core['items'], $proto, array() ) ); + $row_frames = array( $proto ); + } + } else { + foreach ( $frames as $frame ) { + $rows[] = $this->project_row( $core['items'], $frame, null ); + $row_frames[] = $frame; + } + } + + // Window functions. + $this->compute_window_functions( $core['items'], $rows, $row_frames, $cols ); + + // DISTINCT. + if ( $core['distinct'] ) { + $seen = array(); + $deduped = array(); + $deduped_frames = array(); + foreach ( $rows as $index => $row ) { + $key = self::row_key( $row ); + if ( ! isset( $seen[ $key ] ) ) { + $seen[ $key ] = true; + $deduped[] = $row; + $deduped_frames[] = $row_frames[ $index ]; + } + } + $rows = $deduped; + $row_frames = $deduped_frames; + } + + return array( + 'cols' => $cols, + 'rows' => $rows, + 'decl' => $decl, + 'coll' => $coll, + 'aliased' => $aliased_flags, + 'srctable' => $srctable, + 'frames' => $row_frames, + 'items' => $core['items'], + 'positions' => $item_positions, + 'proto' => $proto, + ); + } + + /** + * Project the output schema (column names and declared types) of a + * select item list against a relation prototype. + * + * @param array $items The select item AST nodes. + * @param array $proto The relation prototype frame. + * @return array The column names and declared types. + */ + private function project_schema( $items, $proto ) { + $cols = array(); + $decl = array(); + $coll = array(); + $aliased = array(); + $srctable = array(); + $positions = array(); + foreach ( $items as $index => $item ) { + $positions[ $index ] = count( $cols ); + if ( ! empty( $item['star'] ) ) { + $matched = false; + foreach ( $proto as $slot ) { + if ( null !== $item['tbl'] && strtolower( $item['tbl'] ) !== $slot['alias'] ) { + continue; + } + $matched = true; + foreach ( $slot['names'] as $lower => $original ) { + $cols[] = $original; + $decl[] = isset( $slot['decl'][ $lower ] ) ? $slot['decl'][ $lower ] : null; + $coll[] = isset( $slot['coll'][ $lower ] ) ? $slot['coll'][ $lower ] : 'BINARY'; + $aliased[] = empty( $slot['has_rowid'] ); + $srctable[] = isset( $slot['srct'][ $lower ] ) ? $slot['srct'][ $lower ] : null; + } + } + if ( ! $matched && null !== $item['tbl'] ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $item['tbl'] ); + } + continue; + } + + $expr = $item['e']; + if ( null !== $item['alias'] ) { + $cols[] = $item['alias']; + $aliased[] = true; + } elseif ( 'col' === $expr['t'] ) { + $cols[] = $this->column_output_name( $expr, $proto ); + $aliased[] = $this->column_is_aliased( $expr, $proto ); + } else { + $cols[] = isset( $item['text'] ) ? $item['text'] : '?'; + $aliased[] = true; + } + $srctable[] = 'col' === $expr['t'] ? $this->column_source_table( $expr, $proto ) : null; + + // Only real column references carry a declared type, like in + // SQLite (sqlite3_column_decltype). + if ( 'col' === $expr['t'] ) { + $decl[] = $this->column_decl_type( $expr, $proto ); + } else { + $decl[] = null; + } + $coll[] = $this->expr_collation( $expr, $proto ); + } + return array( $cols, $decl, $coll, $positions, $aliased, $srctable ); + } + + /** + * Get the source table of a bare column reference, when it resolves + * (possibly through subqueries) to a real table column. + * + * @param array $expr The column expression. + * @param array $proto The relation prototype frame. + * @return string|null The source table name, if any. + */ + private function column_source_table( $expr, $proto ) { + $lower = strtolower( $expr['name'] ); + foreach ( $proto as $slot ) { + if ( null !== $expr['tbl'] && strtolower( $expr['tbl'] ) !== $slot['alias'] ) { + continue; + } + if ( isset( $slot['srct'][ $lower ] ) ) { + return $slot['srct'][ $lower ]; + } + if ( isset( $slot['names'][ $lower ] ) ) { + return null; + } + } + return null; + } + + /** + * Check whether a bare column reference resolves to an aliased (sticky) + * output name. References to real table columns are not sticky, so an + * outer query referencing them shows the name as written. + * + * @param array $expr The column expression. + * @param array $proto The relation prototype frame. + * @return bool Whether the name is sticky. + */ + private function column_is_aliased( $expr, $proto ) { + $lower = strtolower( $expr['name'] ); + foreach ( $proto as $slot ) { + if ( null !== $expr['tbl'] && strtolower( $expr['tbl'] ) !== $slot['alias'] ) { + continue; + } + if ( isset( $slot['names'][ $lower ] ) ) { + if ( ! empty( $slot['has_rowid'] ) ) { + return false; + } + return ! isset( $slot['aliased'][ $lower ] ) || $slot['aliased'][ $lower ]; + } + } + return false; + } + + /** + * Project select item values for a single row context. + * + * @param array $items The select item AST nodes. + * @param array $frame The current frame. + * @param array|null $group The group rows (for aggregate context). + * @return array The row values. + */ + private function project_row( $items, $frame, $group ) { + $row = array(); + foreach ( $items as $item ) { + if ( ! empty( $item['star'] ) ) { + foreach ( $frame as $slot ) { + if ( null !== $item['tbl'] && strtolower( $item['tbl'] ) !== $slot['alias'] ) { + continue; + } + foreach ( $slot['names'] as $lower => $original ) { + $row[] = isset( $slot['cols'][ $lower ] ) || array_key_exists( $lower, $slot['cols'] ) + ? $slot['cols'][ $lower ] + : null; + } + } + continue; + } + // Window function values are computed later over the whole + // result set; emit a placeholder for now. + if ( 'fn' === $item['e']['t'] && null !== $item['e']['over'] ) { + $row[] = null; + continue; + } + if ( null !== $group ) { + $row[] = $this->eval_in_group( $item['e'], $frame, $group ); + } else { + $row[] = $this->eval( $item['e'], $frame ); + } + } + return $row; + } + + /** + * Get the output column name for a bare column reference, validating + * that the column exists. + * + * @param array $expr The column expression. + * @param array $frame The current frame. + * @return string The original-case column name. + */ + private function column_output_name( $expr, $frame ) { + $lower = strtolower( $expr['name'] ); + foreach ( $frame as $slot ) { + if ( null !== $expr['tbl'] && strtolower( $expr['tbl'] ) !== $slot['alias'] ) { + continue; + } + if ( isset( $slot['names'][ $lower ] ) ) { + // References to real table columns use the declared name. + if ( ! empty( $slot['has_rowid'] ) ) { + return $slot['names'][ $lower ]; + } + // References to subquery outputs keep aliased (sticky) names; + // plain pass-through column names are shown as written. + $is_sticky = ! isset( $slot['aliased'][ $lower ] ) || $slot['aliased'][ $lower ]; + return $is_sticky ? $slot['names'][ $lower ] : $expr['name']; + } + } + + // rowid aliases resolve against table slots. + if ( in_array( $lower, array( 'rowid', '_rowid_', 'oid' ), true ) ) { + foreach ( $frame as $slot ) { + if ( ( null === $expr['tbl'] || strtolower( $expr['tbl'] ) === $slot['alias'] ) && ! empty( $slot['has_rowid'] ) ) { + return $expr['name']; + } + } + } + + // Outer scopes (correlated subqueries) and trigger NEW/OLD rows. + $tbl = null !== $expr['tbl'] ? strtolower( $expr['tbl'] ) : null; + for ( $i = count( $this->scopes ) - 1; $i >= 0; $i-- ) { + if ( null !== $this->find_column( $this->scopes[ $i ], $tbl, $lower ) ) { + return $expr['name']; + } + foreach ( $this->scopes[ $i ] as $slot ) { + if ( ( null === $tbl || $tbl === $slot['alias'] ) && in_array( $lower, array( 'rowid', '_rowid_', 'oid' ), true ) ) { + return $expr['name']; + } + } + } + + throw new WP_PHP_Engine_SQL_Exception( + 'no such column: ' . ( null !== $expr['tbl'] ? $expr['tbl'] . '.' : '' ) . $expr['name'] + ); + } + + /** + * Get the declared type for a bare column reference. + * + * @param array $expr The column expression. + * @param array $frame The current frame. + * @return string|null The declared type, if known. + */ + private function column_decl_type( $expr, $frame ) { + $lower = strtolower( $expr['name'] ); + foreach ( $frame as $slot ) { + if ( null !== $expr['tbl'] && strtolower( $expr['tbl'] ) !== $slot['alias'] ) { + continue; + } + if ( isset( $slot['decl'][ $lower ] ) ) { + return $slot['decl'][ $lower ]; + } + } + return null; + } + + /** + * Substitute result-column alias references in an expression. + * + * SQLite allows result aliases in WHERE and HAVING clauses. References + * that do not resolve to a real column of the source relation, but match + * a select item alias, are replaced with the aliased expression. + * + * @param array $expr The expression. + * @param array $items The select items. + * @param array $proto The relation prototype frame. + * @return array The substituted expression. + */ + private function substitute_aliases( $expr, $items, $proto ) { + if ( ! is_array( $expr ) || ! isset( $expr['t'] ) ) { + return $expr; + } + if ( 'col' === $expr['t'] && null === $expr['tbl'] ) { + $lower = strtolower( $expr['name'] ); + if ( null === $this->find_column( $proto, null, $lower ) + && ! in_array( $lower, array( 'rowid', '_rowid_', 'oid' ), true ) ) { + foreach ( $items as $item ) { + if ( isset( $item['alias'] ) && null !== $item['alias'] && strtolower( $item['alias'] ) === $lower ) { + return $item['e']; + } + } + } + return $expr; + } + // Recurse structurally, skipping subqueries. + foreach ( $expr as $key => $value ) { + if ( 'sel' === $key || 'sub' === $key || 't' === $key ) { + continue; + } + if ( is_array( $value ) ) { + if ( isset( $value['t'] ) ) { + $expr[ $key ] = $this->substitute_aliases( $value, $items, $proto ); + } else { + foreach ( $value as $index => $child ) { + if ( is_array( $child ) && isset( $child['t'] ) ) { + $expr[ $key ][ $index ] = $this->substitute_aliases( $child, $items, $proto ); + } elseif ( is_array( $child ) ) { + foreach ( $child as $child_key => $grandchild ) { + if ( is_array( $grandchild ) && isset( $grandchild['t'] ) ) { + $expr[ $key ][ $index ][ $child_key ] = $this->substitute_aliases( $grandchild, $items, $proto ); + } + } + } + } + } + } + } + return $expr; + } + + /** + * Resolve a positional (integer literal) or alias reference in GROUP BY + * and ORDER BY to the underlying select item expression. + * + * @param array $expr The expression. + * @param array $items The select items. + * @return array The resolved expression. + */ + private function resolve_positional_or_alias( $expr, $items ) { + if ( 'lit' === $expr['t'] && is_int( $expr['v'] ) ) { + $index = $expr['v'] - 1; + if ( isset( $items[ $index ]['e'] ) ) { + return $items[ $index ]['e']; + } + } + if ( 'col' === $expr['t'] && null === $expr['tbl'] ) { + $lower = strtolower( $expr['name'] ); + foreach ( $items as $item ) { + if ( isset( $item['alias'] ) && null !== $item['alias'] && strtolower( $item['alias'] ) === $lower ) { + return $item['e']; + } + } + } + return $expr; + } + + /** + * Compute window function values and patch them into the result rows. + * + * @param array $items The select items. + * @param array $rows The result rows (modified in place). + * @param array $row_frames The per-row source frames. + * @param array $cols The output column names. + */ + private function compute_window_functions( $items, &$rows, $row_frames, $cols ) { + $column_index = 0; + foreach ( $items as $item ) { + if ( ! empty( $item['star'] ) ) { + // A star expands to multiple columns; recompute the offset. + $column_index = count( $cols ) - count( $items ) + $column_index + 1; + continue; + } + $expr = $item['e']; + if ( 'fn' === $expr['t'] && null !== $expr['over'] ) { + // Partition rows. + $partitions = array(); + foreach ( $rows as $index => $row ) { + $key_parts = array(); + foreach ( $expr['over']['partition'] as $part_expr ) { + $key_parts[] = self::value_key( $this->eval( $part_expr, $row_frames[ $index ] ) ); + } + $key = implode( '|', $key_parts ); + if ( ! isset( $partitions[ $key ] ) ) { + $partitions[ $key ] = array(); + } + $partitions[ $key ][] = $index; + } + foreach ( $partitions as $indexes ) { + // Order within the partition. + if ( ! empty( $expr['over']['order'] ) ) { + $me = $this; + usort( + $indexes, + function ( $a, $b ) use ( $expr, $row_frames, $me ) { + foreach ( $expr['over']['order'] as $order_item ) { + $va = $me->eval( $order_item['e'], $row_frames[ $a ] ); + $vb = $me->eval( $order_item['e'], $row_frames[ $b ] ); + $cmp = WP_PHP_Engine_Values::compare( $va, $vb, null !== $order_item['collate'] ? $order_item['collate'] : 'BINARY' ); + if ( 0 !== $cmp ) { + return 'DESC' === $order_item['dir'] ? -$cmp : $cmp; + } + } + return 0; + } + ); + } + $number = 1; + foreach ( $indexes as $index ) { + switch ( $expr['name'] ) { + case 'row_number': + case 'rank': + case 'dense_rank': + $rows[ $index ][ $column_index ] = $number; + break; + default: + throw new WP_PHP_Engine_SQL_Exception( + 'unsupported window function: ' . $expr['name'] + ); + } + $number += 1; + } + } + } + $column_index += 1; + } + } + + /** + * Sort a result set by an ORDER BY list. + * + * @param array $result The result (modified in place). + * @param array $order The ORDER BY items. + */ + private function sort_result( &$result, $order ) { + $rows = $result['rows']; + $frames = isset( $result['frames'] ) ? $result['frames'] : null; + $items = isset( $result['items'] ) ? $result['items'] : array(); + $cols = $result['cols']; + $positions = isset( $result['positions'] ) ? $result['positions'] : array(); + + // SQLite resolves ORDER BY terms at prepare time; with no rows, the + // terms are never evaluated, so resolve them once explicitly. + if ( 0 === count( $rows ) && null !== $frames && isset( $result['proto'] ) ) { + foreach ( $order as $order_item ) { + $this->order_value( $order_item['e'], null, null, $cols, $items, array( $result['proto'] ), $positions ); + } + return; + } + + // Precompute sort keys. + $keys = array(); + foreach ( $rows as $index => $row ) { + $key = array(); + foreach ( $order as $order_item ) { + $key[] = $this->order_value( $order_item['e'], $row, $index, $cols, $items, $frames, $positions ); + } + $keys[ $index ] = $key; + } + + $indexes = array_keys( $rows ); + usort( + $indexes, + function ( $a, $b ) use ( $keys, $order ) { + foreach ( $order as $i => $order_item ) { + $collate = null !== $order_item['collate'] ? $order_item['collate'] : 'BINARY'; + $cmp = WP_PHP_Engine_Values::compare( $keys[ $a ][ $i ], $keys[ $b ][ $i ], $collate ); + if ( 0 !== $cmp ) { + return 'DESC' === $order_item['dir'] ? -$cmp : $cmp; + } + } + return $a - $b; // Stable sort. + } + ); + + $sorted_rows = array(); + $sorted_frames = array(); + foreach ( $indexes as $index ) { + $sorted_rows[] = $rows[ $index ]; + if ( null !== $frames && isset( $frames[ $index ] ) ) { + $sorted_frames[] = $frames[ $index ]; + } + } + $result['rows'] = $sorted_rows; + if ( null !== $frames ) { + $result['frames'] = $sorted_frames; + } + } + + /** + * Evaluate an ORDER BY expression for a result row. + * + * Handles ordinal references, output column names, and arbitrary + * expressions evaluated against the row's source frame. + * + * @param array $expr The ORDER BY expression. + * @param array $row The output row. + * @param int $index The row index. + * @param array $cols The output column names. + * @param array $items The select items. + * @param array|null $frames The per-row source frames. + * @return mixed The sort value. + */ + private function order_value( $expr, $row, $index, $cols, $items, $frames, $positions = array() ) { + // Ordinal reference. + if ( 'lit' === $expr['t'] && is_int( $expr['v'] ) ) { + $position = $expr['v'] - 1; + if ( $position < 0 || $position >= count( $cols ) ) { + throw new WP_PHP_Engine_SQL_Exception( + sprintf( '%d ORDER BY term out of range - should be between 1 and %d', $expr['v'], count( $cols ) ) + ); + } + return null !== $row ? $row[ $position ] : null; + } + + if ( 'col' === $expr['t'] && null === $expr['tbl'] ) { + $lower = strtolower( $expr['name'] ); + + // An explicit select item alias takes priority (the first match + // wins when the same alias is used multiple times). + foreach ( $items as $item_index => $item ) { + if ( isset( $item['alias'] ) && null !== $item['alias'] && strtolower( $item['alias'] ) === $lower ) { + $position = isset( $positions[ $item_index ] ) ? $positions[ $item_index ] : $item_index; + return null !== $row ? $row[ $position ] : null; + } + } + + // Without source frames (compound queries), match output names. + if ( null === $frames ) { + foreach ( $cols as $position => $name ) { + if ( strtolower( $name ) === $lower ) { + return null !== $row ? $row[ $position ] : null; + } + } + } + } + + // General expression against the source frame. + if ( null !== $frames ) { + if ( null === $index ) { + // Prepare-time resolution against the prototype frame. + return $this->eval( $expr, $frames[0] ); + } + if ( isset( $frames[ $index ] ) ) { + return $this->eval( $expr, $frames[ $index ] ); + } + } + + throw new WP_PHP_Engine_SQL_Exception( + sprintf( '1st ORDER BY term does not match any column in the result set' ) + ); + } + + /* + * ---------------------------------------------------------------------- + * FROM resolution. + * ---------------------------------------------------------------------- + */ + + /** + * Resolve a FROM clause for an UPDATE ... FROM statement. + * + * @param array $ref The from-reference AST node. + * @return array array( 'proto' => frame, 'frames' => frame[] ). + */ + public function resolve_update_from( $ref ) { + return $this->resolve_from( $ref ); + } + + /** + * Resolve a FROM clause node into a schema prototype and a frame list. + * + * @param array $ref The from-reference AST node. + * @return array array( 'proto' => frame, 'frames' => frame[] ). + */ + private function resolve_from( $ref ) { + switch ( $ref['t'] ) { + case 'table': + case 'subquery': + case 'tablefn': + case 'values': + $relation = $this->relation_slots( $ref ); + $frames = array(); + foreach ( $relation['slots'] as $slot ) { + $frames[] = array( $slot ); + } + return array( + 'proto' => array( $relation['proto'] ), + 'frames' => $frames, + ); + + case 'join': + return $this->resolve_join( $ref ); + } + throw new WP_PHP_Engine_SQL_Exception( 'unsupported FROM clause' ); + } + + /** + * Resolve a join node into a schema prototype and a frame list. + * + * @param array $ref The join AST node. + * @return array array( 'proto' => frame, 'frames' => frame[] ). + */ + private function resolve_join( $ref ) { + $kind = $ref['kind']; + $left = $this->resolve_from( $ref['l'] ); + $right = $this->resolve_from( $ref['r'] ); + + if ( 'RIGHT' === $kind ) { + // Evaluate as a LEFT JOIN with sides swapped; the output column + // order keeps the original left side first. + $frames = $this->join_frames( $right, $left, 'LEFT', $ref['on'], $ref['using'], true ); + } else { + $frames = $this->join_frames( $left, $right, $kind, $ref['on'], $ref['using'], false ); + } + return array( + 'proto' => array_merge( $left['proto'], $right['proto'] ), + 'frames' => $frames, + ); + } + + /** + * Join two resolved relations. + * + * @param array $left The left resolved relation. + * @param array $right The right resolved relation. + * @param string $kind The join kind: INNER, LEFT, or CROSS. + * @param array|null $on The ON condition. + * @param array|null $using The USING column list. + * @param bool $swapped Whether the sides were swapped (RIGHT JOIN). + * @return array The joined frames. + */ + private function join_frames( $left, $right, $kind, $on, $using, $swapped ) { + $result = array(); + foreach ( $left['frames'] as $left_frame ) { + $matched = false; + foreach ( $right['frames'] as $right_frame ) { + $combined = $swapped + ? array_merge( $right_frame, $left_frame ) + : array_merge( $left_frame, $right_frame ); + if ( null !== $on ) { + if ( ! WP_PHP_Engine_Values::is_truthy( $this->eval( $on, $combined ) ) ) { + continue; + } + } elseif ( null !== $using ) { + $match = true; + foreach ( $using as $col ) { + $lower = strtolower( $col ); + $lv = $this->slot_list_value( $left_frame, $lower ); + $rv = $this->slot_list_value( $right_frame, $lower ); + if ( 0 !== WP_PHP_Engine_Values::compare( $lv, $rv ) || null === $lv ) { + $match = false; + break; + } + } + if ( ! $match ) { + continue; + } + } + $matched = true; + $result[] = $combined; + } + if ( ! $matched && 'LEFT' === $kind ) { + // Null-fill the right side using its prototype. + $null_slots = array(); + foreach ( $right['proto'] as $slot ) { + $null_slots[] = $slot; + } + $result[] = $swapped + ? array_merge( $null_slots, $left_frame ) + : array_merge( $left_frame, $null_slots ); + } + } + return $result; + } + + /** + * Get a column value from a list of slots. + * + * @param array $slots The slots. + * @param string $lower The lowercase column name. + * @return mixed The value. + */ + private function slot_list_value( $slots, $lower ) { + foreach ( $slots as $slot ) { + if ( isset( $slot['cols'][ $lower ] ) || array_key_exists( $lower, $slot['cols'] ) ) { + return $slot['cols'][ $lower ]; + } + } + return null; + } + + /** + * Materialize a relation (table, subquery, VALUES, or table function) + * into a prototype slot and a list of row slots. + * + * @param array $ref The from-reference AST node. + * @return array array( 'proto' => slot, 'slots' => slot[] ). + */ + private function relation_slots( $ref ) { + if ( 'table' === $ref['t'] ) { + $lower = strtolower( $ref['name'] ); + $alias = null !== $ref['alias'] ? strtolower( $ref['alias'] ) : $lower; + + // Common table expressions take priority. + if ( isset( $this->ctes[ $lower ] ) ) { + $result = $this->materialize_cte( $lower ); + return $this->result_to_slots( $result, $alias ); + } + + // Virtual tables provided by the engine (sqlite_master, etc.). + $virtual = $this->engine->virtual_table_result( $lower, isset( $ref['db'] ) ? $ref['db'] : null ); + if ( null !== $virtual ) { + return $this->result_to_slots( $virtual, $alias ); + } + + $table = $this->engine->get_table( $lower ); + if ( null === $table ) { + // Views behave like CTEs. + $view = $this->engine->get_view( $lower ); + if ( null !== $view ) { + $result = $this->select( $view['sel'] ); + return $this->result_to_slots( $result, $alias ); + } + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $ref['name'] ); + } + return $this->table_slots( $table, $alias ); + } + + if ( 'subquery' === $ref['t'] ) { + $result = $this->select( $ref['sel'] ); + $alias = null !== $ref['alias'] ? strtolower( $ref['alias'] ) : null; + return $this->result_to_slots( $result, $alias ); + } + + if ( 'values' === $ref['t'] ) { + $result = $this->select_core( $ref ); + $alias = null !== $ref['alias'] ? strtolower( $ref['alias'] ) : null; + return $this->result_to_slots( $result, $alias ); + } + + if ( 'tablefn' === $ref['t'] ) { + $args = array(); + foreach ( $ref['args'] as $arg ) { + $args[] = $this->eval( $arg, array() ); + } + $result = $this->engine->table_function_result( $ref['name'], $args ); + if ( null === $result ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $ref['name'] ); + } + $alias = null !== $ref['alias'] ? strtolower( $ref['alias'] ) : $ref['name']; + return $this->result_to_slots( $result, $alias ); + } + + throw new WP_PHP_Engine_SQL_Exception( 'unsupported FROM clause' ); + } + + /** + * Materialize a CTE by name. + * + * @param string $lower The lowercase CTE name. + * @return array The result set. + */ + private function materialize_cte( $lower ) { + $cte = $this->ctes[ $lower ]; + if ( isset( $cte['materialized'] ) ) { + return $cte['materialized']; + } + // Hide the CTE itself during materialization (no recursion support). + $saved = $this->ctes; + unset( $this->ctes[ $lower ] ); + $result = $this->select( $cte['sel'] ); + $this->ctes = $saved; + if ( null !== $cte['cols'] ) { + $result['cols'] = $cte['cols']; + } + $this->ctes[ $lower ]['materialized'] = $result; + return $result; + } + + /** + * Convert a result set into a prototype slot and a list of row slots. + * + * @param array $result The result set. + * @param string|null $alias The lowercase alias. + * @return array array( 'proto' => slot, 'slots' => slot[] ). + */ + private function result_to_slots( $result, $alias ) { + $names = array(); + $decl = array(); + $coll = array(); + $aliased = array(); + $srct = array(); + foreach ( $result['cols'] as $index => $name ) { + $lower = strtolower( $name ); + if ( isset( $names[ $lower ] ) ) { + // Disambiguate duplicate output names like SQLite (name:1). + $lower = $lower . ':' . $index; + } + $names[ $lower ] = $name; + $decl[ $lower ] = isset( $result['decl'][ $index ] ) ? $result['decl'][ $index ] : null; + $coll[ $lower ] = isset( $result['coll'][ $index ] ) ? $result['coll'][ $index ] : 'BINARY'; + $aliased[ $lower ] = ! isset( $result['aliased'][ $index ] ) || $result['aliased'][ $index ]; + $srct[ $lower ] = isset( $result['srctable'][ $index ] ) ? $result['srctable'][ $index ] : null; + } + $lowers = array_keys( $names ); + + $proto = array( + 'alias' => $alias, + 'cols' => array_fill_keys( $lowers, null ), + 'names' => $names, + 'aff' => array(), + 'coll' => $coll, + 'decl' => $decl, + 'aliased' => $aliased, + 'srct' => $srct, + 'rowid' => null, + 'has_rowid' => false, + ); + + $slots = array(); + foreach ( $result['rows'] as $row ) { + $cols = array(); + foreach ( $lowers as $position => $lower ) { + $cols[ $lower ] = isset( $row[ $position ] ) || array_key_exists( $position, $row ) ? $row[ $position ] : null; + } + $slot = $proto; + $slot['cols'] = $cols; + $slots[] = $slot; + } + + return array( + 'proto' => $proto, + 'slots' => $slots, + ); + } + + /** + * Convert table rows into a prototype slot and a list of row slots. + * + * @param array $table The table definition. + * @param string $alias The lowercase alias. + * @return array array( 'proto' => slot, 'slots' => slot[] ). + */ + private function table_slots( $table, $alias ) { + $proto = $this->engine->make_table_slot( $table, array_fill_keys( array_keys( $table['columns'] ), null ), null, $alias ); + + // Scan rows in ascending rowid order, like SQLite does. + $rows = $table['rows']; + ksort( $rows ); + + $slots = array(); + foreach ( $rows as $rowid => $row ) { + $slot = $proto; + $slot['cols'] = $row; + $slot['rowid'] = $rowid; + $slots[] = $slot; + } + return array( + 'proto' => $proto, + 'slots' => $slots, + ); + } + + /* + * ---------------------------------------------------------------------- + * Expression evaluation. + * ---------------------------------------------------------------------- + */ + + /** + * Check whether an expression contains an aggregate function call + * (not nested inside a subquery). + * + * @param array $expr The expression. + * @return bool Whether an aggregate call is present. + */ + private function contains_aggregate( $expr ) { + if ( ! is_array( $expr ) ) { + return false; + } + if ( isset( $expr['t'] ) ) { + if ( 'fn' === $expr['t'] && null === $expr['over'] + && isset( WP_PHP_Engine_Functions::$aggregate_functions[ $expr['name'] ] ) + && ! $this->engine->has_user_function( $expr['name'] ) ) { + return true; + } + if ( 'sub' === $expr['t'] || 'exists' === $expr['t'] ) { + return false; + } + foreach ( $expr as $key => $value ) { + if ( 'sel' === $key || 'sub' === $key ) { + continue; + } + if ( is_array( $value ) && $this->contains_aggregate( $value ) ) { + return true; + } + } + return false; + } + // A plain list (e.g. CASE when-pairs or function arguments). + foreach ( $expr as $value ) { + if ( is_array( $value ) && $this->contains_aggregate( $value ) ) { + return true; + } + } + return false; + } + + /** + * Evaluate an expression in a group (aggregate) context. + * + * @param array $expr The expression. + * @param array $frame The representative frame (first row of the group). + * @param array $group All frames in the group. + * @return mixed The value. + */ + public function eval_in_group( $expr, $frame, $group ) { + if ( 'fn' === $expr['t'] && null === $expr['over'] + && isset( WP_PHP_Engine_Functions::$aggregate_functions[ $expr['name'] ] ) + && ! $this->engine->has_user_function( $expr['name'] ) ) { + return $this->eval_aggregate( $expr, $group ); + } + if ( in_array( $expr['t'], array( 'lit', 'param', 'now' ), true ) ) { + return $this->eval( $expr, $frame ); + } + if ( 'col' === $expr['t'] ) { + return $this->eval( $expr, $frame ); + } + + // Recurse structurally: clone the node with evaluated children. + switch ( $expr['t'] ) { + case 'bin': + // Logical operators need lazy evaluation; emulate by + // evaluating both sides in group context. + $node = $expr; + $node['l'] = array( + 't' => 'lit', + 'v' => $this->eval_in_group( $expr['l'], $frame, $group ), + ); + $node['r'] = array( + 't' => 'lit', + 'v' => $this->eval_in_group( $expr['r'], $frame, $group ), + ); + return $this->eval( $node, $frame ); + case 'un': + $node = $expr; + $node['e'] = array( + 't' => 'lit', + 'v' => $this->eval_in_group( $expr['e'], $frame, $group ), + ); + return $this->eval( $node, $frame ); + case 'fn': + $node = $expr; + foreach ( $expr['args'] as $index => $arg ) { + $node['args'][ $index ] = array( + 't' => 'lit', + 'v' => $this->eval_in_group( $arg, $frame, $group ), + ); + } + return $this->eval( $node, $frame ); + case 'case': + if ( null !== $expr['operand'] ) { + $operand = $this->eval_in_group( $expr['operand'], $frame, $group ); + foreach ( $expr['when'] as $when ) { + $test = $this->eval_in_group( $when[0], $frame, $group ); + if ( null !== $operand && null !== $test && 0 === WP_PHP_Engine_Values::compare( $operand, $test ) ) { + return $this->eval_in_group( $when[1], $frame, $group ); + } + } + } else { + foreach ( $expr['when'] as $when ) { + if ( WP_PHP_Engine_Values::is_truthy( $this->eval_in_group( $when[0], $frame, $group ) ) ) { + return $this->eval_in_group( $when[1], $frame, $group ); + } + } + } + return null !== $expr['else'] ? $this->eval_in_group( $expr['else'], $frame, $group ) : null; + case 'cast': + return WP_PHP_Engine_Values::cast( + $this->eval_in_group( $expr['e'], $frame, $group ), + $expr['as'] + ); + case 'in': + case 'like': + case 'between': + case 'is': + case 'isnull': + case 'collate': + // Pre-evaluate child expressions in the group context, then + // evaluate the operator on the resulting literals. + $node = $expr; + foreach ( array( 'e', 'l', 'r', 'p', 'lo', 'hi', 'escape' ) as $key ) { + if ( isset( $node[ $key ] ) && is_array( $node[ $key ] ) && isset( $node[ $key ]['t'] ) ) { + $node[ $key ] = array( + 't' => 'lit', + 'v' => $this->eval_in_group( $expr[ $key ], $frame, $group ), + ); + } + } + if ( isset( $node['list'] ) ) { + foreach ( $node['list'] as $index => $item ) { + $node['list'][ $index ] = array( + 't' => 'lit', + 'v' => $this->eval_in_group( $item, $frame, $group ), + ); + } + } + return $this->eval( $node, $frame ); + default: + return $this->eval( $expr, $frame ); + } + } + + /** + * Evaluate an aggregate function over a group of frames. + * + * @param array $expr The aggregate function expression. + * @param array $group The frames in the group. + * @return mixed The aggregate value. + */ + private function eval_aggregate( $expr, $group ) { + $name = $expr['name']; + + // COUNT(*). + if ( $expr['star'] ) { + return count( $group ); + } + + // Collect argument values. + $values = array(); + $second_arg = null; + foreach ( $group as $frame ) { + $value = $this->eval( $expr['args'][0], $frame ); + if ( isset( $expr['args'][1] ) ) { + $second_arg = $this->eval( $expr['args'][1], $frame ); + } + if ( null === $value ) { + continue; + } + $values[] = $value; + } + + if ( $expr['distinct'] ) { + $seen = array(); + $unique = array(); + foreach ( $values as $value ) { + $key = self::value_key( $value ); + if ( ! isset( $seen[ $key ] ) ) { + $seen[ $key ] = true; + $unique[] = $value; + } + } + $values = $unique; + } + + switch ( $name ) { + case 'count': + return count( $values ); + case 'sum': + case 'total': + if ( 0 === count( $values ) ) { + return 'total' === $name ? 0.0 : null; + } + $sum = 0; + $is_int = 'sum' === $name; + foreach ( $values as $value ) { + $number = is_int( $value ) || is_float( $value ) ? $value : WP_PHP_Engine_Values::to_numeric( $value ); + if ( is_float( $number ) ) { + $is_int = false; + } + $sum += $number; + } + if ( 'total' === $name ) { + return (float) $sum; + } + return $is_int && is_int( $sum ) ? $sum : (float) $sum; + case 'avg': + if ( 0 === count( $values ) ) { + return null; + } + $sum = 0.0; + foreach ( $values as $value ) { + $sum += (float) WP_PHP_Engine_Values::to_numeric( $value ); + } + return $sum / count( $values ); + case 'min': + $min = null; + foreach ( $values as $value ) { + if ( null === $min || WP_PHP_Engine_Values::compare( $value, $min ) < 0 ) { + $min = $value; + } + } + return $min; + case 'max': + $max = null; + foreach ( $values as $value ) { + if ( null === $max || WP_PHP_Engine_Values::compare( $value, $max ) > 0 ) { + $max = $value; + } + } + return $max; + case 'group_concat': + case 'string_agg': + if ( 0 === count( $values ) ) { + return null; + } + $separator = isset( $expr['args'][1] ) ? ( null === $second_arg ? ',' : WP_PHP_Engine_Values::to_text( $second_arg ) ) : ','; + $parts = array(); + foreach ( $values as $value ) { + $parts[] = WP_PHP_Engine_Values::to_text( $value ); + } + return implode( $separator, $parts ); + } + throw new WP_PHP_Engine_SQL_Exception( 'unsupported aggregate function: ' . $name ); + } + + /** + * Evaluate an expression against a frame. + * + * @param array $expr The expression AST node. + * @param array $frame The current frame. + * @return mixed The value. + */ + public function eval( $expr, $frame ) { + switch ( $expr['t'] ) { + case 'lit': + return $expr['v']; + + case 'param': + $index = $expr['i']; + if ( is_int( $index ) ) { + if ( ! array_key_exists( $index, $this->params ) ) { + // PDO uses 1-based keys when bound explicitly. + if ( array_key_exists( $index + 1, $this->params ) ) { + return $this->normalize_param( $this->params[ $index + 1 ] ); + } + return null; + } + return $this->normalize_param( $this->params[ $index ] ); + } + if ( array_key_exists( $index, $this->params ) ) { + return $this->normalize_param( $this->params[ $index ] ); + } + $bare = ltrim( (string) $index, ':@$' ); + if ( array_key_exists( $bare, $this->params ) ) { + return $this->normalize_param( $this->params[ $bare ] ); + } + if ( array_key_exists( ':' . $bare, $this->params ) ) { + return $this->normalize_param( $this->params[ ':' . $bare ] ); + } + return null; + + case 'col': + return $this->resolve_column( $expr, $frame ); + + case 'now': + return $this->engine->current_timestamp( $expr['fn'] ); + + case 'bin': + return $this->eval_binary( $expr, $frame ); + + case 'un': + $value = $this->eval( $expr['e'], $frame ); + switch ( $expr['op'] ) { + case 'NOT': + if ( null === $value ) { + return null; + } + return WP_PHP_Engine_Values::is_truthy( $value ) ? 0 : 1; + case '-': + if ( null === $value ) { + return null; + } + $number = WP_PHP_Engine_Values::to_numeric( $value ); + return is_int( $number ) ? -$number : - (float) $number; + case '+': + return $value; + case '~': + if ( null === $value ) { + return null; + } + return ~ (int) WP_PHP_Engine_Values::to_numeric( $value ); + } + return null; + + case 'collate': + return $this->eval( $expr['e'], $frame ); + + case 'case': + if ( null !== $expr['operand'] ) { + $operand = $this->eval( $expr['operand'], $frame ); + foreach ( $expr['when'] as $when ) { + $test = $this->eval( $when[0], $frame ); + if ( null !== $operand && null !== $test && 0 === WP_PHP_Engine_Values::compare( $operand, $test ) ) { + return $this->eval( $when[1], $frame ); + } + } + } else { + foreach ( $expr['when'] as $when ) { + if ( WP_PHP_Engine_Values::is_truthy( $this->eval( $when[0], $frame ) ) ) { + return $this->eval( $when[1], $frame ); + } + } + } + return null !== $expr['else'] ? $this->eval( $expr['else'], $frame ) : null; + + case 'cast': + return WP_PHP_Engine_Values::cast( $this->eval( $expr['e'], $frame ), $expr['as'] ); + + case 'in': + return $this->eval_in( $expr, $frame ); + + case 'like': + return $this->eval_like( $expr, $frame ); + + case 'between': + $value = $this->eval( $expr['e'], $frame ); + $lo = $this->eval( $expr['lo'], $frame ); + $hi = $this->eval( $expr['hi'], $frame ); + if ( null === $value || null === $lo || null === $hi ) { + return null; + } + $affinity = $this->expr_affinity( $expr['e'], $frame ); + if ( null !== $affinity && WP_PHP_Engine_Values::AFFINITY_BLOB !== $affinity ) { + list( $value_lo, $lo ) = WP_PHP_Engine_Values::apply_comparison_affinity( $value, $lo, $affinity, $this->expr_affinity( $expr['lo'], $frame ) ); + list( $value_hi, $hi ) = WP_PHP_Engine_Values::apply_comparison_affinity( $value, $hi, $affinity, $this->expr_affinity( $expr['hi'], $frame ) ); + } else { + $value_lo = $value; + $value_hi = $value; + } + $collation = $this->expr_collation( $expr['e'], $frame ); + $in_range = WP_PHP_Engine_Values::compare( $value_lo, $lo, $collation ) >= 0 + && WP_PHP_Engine_Values::compare( $value_hi, $hi, $collation ) <= 0; + if ( $expr['not'] ) { + return $in_range ? 0 : 1; + } + return $in_range ? 1 : 0; + + case 'is': + $left = $this->eval( $expr['l'], $frame ); + $right = $this->eval( $expr['r'], $frame ); + if ( null === $left || null === $right ) { + $equal = null === $left && null === $right; + } else { + $equal = 0 === WP_PHP_Engine_Values::compare( $left, $right, $this->comparison_collation( $expr['l'], $expr['r'], $frame ) ); + } + return ( $expr['not'] ? ! $equal : $equal ) ? 1 : 0; + + case 'isnull': + $value = $this->eval( $expr['e'], $frame ); + $is_null = null === $value; + return ( $expr['not'] ? ! $is_null : $is_null ) ? 1 : 0; + + case 'exists': + $result = $this->run_subquery( $expr['sel'], $frame ); + $exists = count( $result['rows'] ) > 0; + return ( $expr['not'] ? ! $exists : $exists ) ? 1 : 0; + + case 'sub': + $result = $this->run_subquery( $expr['sel'], $frame ); + if ( 0 === count( $result['rows'] ) ) { + return null; + } + return $result['rows'][0][0]; + + case 'fn': + return $this->eval_function( $expr, $frame ); + + case 'raise': + if ( 'IGNORE' === strtoupper( $expr['kind'] ) ) { + throw new WP_PHP_Engine_Raise_Ignore_Exception(); + } + $message = null !== $expr['msg'] ? WP_PHP_Engine_Values::to_text( $this->eval( $expr['msg'], $frame ) ) : ''; + throw new WP_PHP_Engine_SQL_Exception( $message, 'HY000', 19 ); + + case 'row': + // Row values are only supported in simple comparisons. + $values = array(); + foreach ( $expr['exprs'] as $sub_expr ) { + $values[] = $this->eval( $sub_expr, $frame ); + } + return $values; + } + throw new WP_PHP_Engine_SQL_Exception( 'unsupported expression' ); + } + + /** + * Normalize a bound parameter value. + * + * @param mixed $value The bound value. + * @return mixed The normalized value. + */ + private function normalize_param( $value ) { + if ( is_bool( $value ) ) { + return $value ? 1 : 0; + } + return $value; + } + + /** + * Run a subquery with the current frame pushed onto the scope stack. + * + * @param array $select The select AST node. + * @param array $frame The current frame. + * @return array The result set. + */ + private function run_subquery( $select, $frame ) { + $this->scopes[] = $frame; + try { + return $this->select( $select ); + } finally { + array_pop( $this->scopes ); + } + } + + /** + * Evaluate a subquery expression to its first result row. + * + * This is used for multi-column assignments: SET (a, b) = (SELECT ...). + * + * @param array $expr The subquery expression node. + * @param array $frame The current frame. + * @return array|null The first row, or null when there are no rows. + */ + public function eval_row_subquery( $expr, $frame ) { + if ( 'sub' !== $expr['t'] ) { + throw new WP_PHP_Engine_SQL_Exception( 'expected a subquery on the right-hand side of a multi-column assignment' ); + } + $result = $this->run_subquery( $expr['sel'], $frame ); + if ( 0 === count( $result['rows'] ) ) { + return null; + } + return $result['rows'][0]; + } + + /** + * Resolve a column reference against the frame and outer scopes. + * + * @param array $expr The column expression. + * @param array $frame The current frame. + * @return mixed The value. + */ + private function resolve_column( $expr, $frame ) { + $lower = strtolower( $expr['name'] ); + $tbl = null !== $expr['tbl'] ? strtolower( $expr['tbl'] ) : null; + + $found = $this->find_column( $frame, $tbl, $lower ); + if ( null !== $found ) { + return $found[0]['cols'][ $found[1] ]; + } + + // rowid aliases. + if ( null === $found && in_array( $lower, array( 'rowid', '_rowid_', 'oid' ), true ) ) { + foreach ( $frame as $slot ) { + if ( null !== $tbl && $tbl !== $slot['alias'] ) { + continue; + } + if ( ! empty( $slot['has_rowid'] ) ) { + return $slot['rowid']; + } + } + } + + // Outer scopes (correlated subqueries). + for ( $i = count( $this->scopes ) - 1; $i >= 0; $i-- ) { + $found = $this->find_column( $this->scopes[ $i ], $tbl, $lower ); + if ( null !== $found ) { + return $found[0]['cols'][ $found[1] ]; + } + if ( in_array( $lower, array( 'rowid', '_rowid_', 'oid' ), true ) ) { + foreach ( $this->scopes[ $i ] as $slot ) { + if ( null !== $tbl && $tbl !== $slot['alias'] ) { + continue; + } + if ( ! empty( $slot['has_rowid'] ) ) { + return $slot['rowid']; + } + } + } + } + + throw new WP_PHP_Engine_SQL_Exception( + 'no such column: ' . ( null !== $expr['tbl'] ? $expr['tbl'] . '.' : '' ) . $expr['name'] + ); + } + + /** + * Find a column in a frame. + * + * Unqualified references that match columns in multiple slots are + * ambiguous, like in SQLite. + * + * @param array $frame The frame. + * @param string|null $tbl The lowercase table alias, if qualified. + * @param string $lower The lowercase column name. + * @return array|null The slot and key, or null. + * @throws WP_PHP_Engine_SQL_Exception When the reference is ambiguous. + */ + private function find_column( $frame, $tbl, $lower ) { + $found = null; + foreach ( $frame as $slot ) { + if ( null !== $tbl && $tbl !== $slot['alias'] ) { + continue; + } + if ( isset( $slot['cols'][ $lower ] ) || array_key_exists( $lower, $slot['cols'] ) ) { + if ( null !== $tbl ) { + return array( $slot, $lower ); + } + if ( null !== $found ) { + throw new WP_PHP_Engine_SQL_Exception( 'ambiguous column name: ' . $lower ); + } + $found = array( $slot, $lower ); + } + } + return $found; + } + + /** + * Determine the affinity of an expression. + * + * @param array $expr The expression. + * @param array $frame The current frame. + * @return string|null The affinity, or null when none applies. + */ + public function expr_affinity( $expr, $frame ) { + if ( 'col' === $expr['t'] ) { + $lower = strtolower( $expr['name'] ); + $tbl = null !== $expr['tbl'] ? strtolower( $expr['tbl'] ) : null; + $found = $this->find_column( $frame, $tbl, $lower ); + if ( null !== $found && isset( $found[0]['aff'][ $lower ] ) ) { + return $found[0]['aff'][ $lower ]; + } + if ( null === $found ) { + for ( $i = count( $this->scopes ) - 1; $i >= 0; $i-- ) { + $found = $this->find_column( $this->scopes[ $i ], $tbl, $lower ); + if ( null !== $found ) { + return isset( $found[0]['aff'][ $lower ] ) ? $found[0]['aff'][ $lower ] : null; + } + } + } + if ( in_array( $lower, array( 'rowid', '_rowid_', 'oid' ), true ) ) { + return WP_PHP_Engine_Values::AFFINITY_INTEGER; + } + return null; + } + if ( 'cast' === $expr['t'] ) { + return WP_PHP_Engine_Values::affinity_for_type( $expr['as'] ); + } + if ( 'collate' === $expr['t'] || 'un' === $expr['t'] ) { + return isset( $expr['e'] ) ? $this->expr_affinity( $expr['e'], $frame ) : null; + } + return null; + } + + /** + * Determine the collation of an expression. + * + * @param array $expr The expression. + * @param array $frame The current frame. + * @return string The collation name. + */ + public function expr_collation( $expr, $frame ) { + if ( 'collate' === $expr['t'] ) { + return $expr['name']; + } + if ( 'col' === $expr['t'] ) { + $lower = strtolower( $expr['name'] ); + $tbl = null !== $expr['tbl'] ? strtolower( $expr['tbl'] ) : null; + $found = $this->find_column( $frame, $tbl, $lower ); + if ( null === $found ) { + for ( $i = count( $this->scopes ) - 1; $i >= 0; $i-- ) { + $found = $this->find_column( $this->scopes[ $i ], $tbl, $lower ); + if ( null !== $found ) { + break; + } + } + } + if ( null !== $found && isset( $found[0]['coll'][ $lower ] ) ) { + return $found[0]['coll'][ $lower ]; + } + return 'BINARY'; + } + if ( 'un' === $expr['t'] ) { + return $this->expr_collation( $expr['e'], $frame ); + } + if ( 'bin' === $expr['t'] && '||' === $expr['op'] ) { + $left = $this->expr_collation( $expr['l'], $frame ); + if ( 'BINARY' !== $left ) { + return $left; + } + return $this->expr_collation( $expr['r'], $frame ); + } + return 'BINARY'; + } + + /** + * Determine the collation for a comparison of two expressions. + * + * @param array $left The left expression. + * @param array $right The right expression. + * @param array $frame The current frame. + * @return string The collation name. + */ + private function comparison_collation( $left, $right, $frame ) { + // An explicit COLLATE operator takes precedence; otherwise the + // left operand's collation applies, then the right one's. + if ( 'collate' === $left['t'] ) { + return $left['name']; + } + if ( 'collate' === $right['t'] ) { + return $right['name']; + } + $collation = $this->expr_collation( $left, $frame ); + if ( 'BINARY' !== $collation ) { + return $collation; + } + return $this->expr_collation( $right, $frame ); + } + + /** + * Evaluate a binary operator expression. + * + * @param array $expr The expression. + * @param array $frame The current frame. + * @return mixed The value. + */ + private function eval_binary( $expr, $frame ) { + $op = $expr['op']; + + // Logical operators with SQL three-valued logic. + if ( 'AND' === $op ) { + $left = $this->eval( $expr['l'], $frame ); + if ( null !== $left && ! WP_PHP_Engine_Values::is_truthy( $left ) ) { + return 0; + } + $right = $this->eval( $expr['r'], $frame ); + if ( null !== $right && ! WP_PHP_Engine_Values::is_truthy( $right ) ) { + return 0; + } + if ( null === $left || null === $right ) { + return null; + } + return 1; + } + if ( 'OR' === $op ) { + $left = $this->eval( $expr['l'], $frame ); + if ( null !== $left && WP_PHP_Engine_Values::is_truthy( $left ) ) { + return 1; + } + $right = $this->eval( $expr['r'], $frame ); + if ( null !== $right && WP_PHP_Engine_Values::is_truthy( $right ) ) { + return 1; + } + if ( null === $left || null === $right ) { + return null; + } + return 0; + } + + $left = $this->eval( $expr['l'], $frame ); + $right = $this->eval( $expr['r'], $frame ); + + // Comparison operators. + if ( in_array( $op, array( '=', '!=', '<', '<=', '>', '>=' ), true ) ) { + if ( null === $left || null === $right ) { + return null; + } + list( $left, $right ) = WP_PHP_Engine_Values::apply_comparison_affinity( + $left, + $right, + $this->expr_affinity( $expr['l'], $frame ), + $this->expr_affinity( $expr['r'], $frame ) + ); + $cmp = WP_PHP_Engine_Values::compare( $left, $right, $this->comparison_collation( $expr['l'], $expr['r'], $frame ) ); + switch ( $op ) { + case '=': + return 0 === $cmp ? 1 : 0; + case '!=': + return 0 !== $cmp ? 1 : 0; + case '<': + return $cmp < 0 ? 1 : 0; + case '<=': + return $cmp <= 0 ? 1 : 0; + case '>': + return $cmp > 0 ? 1 : 0; + case '>=': + return $cmp >= 0 ? 1 : 0; + } + } + + // String concatenation. + if ( '||' === $op ) { + if ( null === $left || null === $right ) { + return null; + } + return WP_PHP_Engine_Values::to_text( $left ) . WP_PHP_Engine_Values::to_text( $right ); + } + + // Arithmetic and bitwise operators. + if ( null === $left || null === $right ) { + return null; + } + $ln = WP_PHP_Engine_Values::to_numeric( $left ); + $rn = WP_PHP_Engine_Values::to_numeric( $right ); + switch ( $op ) { + case '+': + return $ln + $rn; + case '-': + return $ln - $rn; + case '*': + return $ln * $rn; + case '/': + if ( 0 == $rn ) { // phpcs:ignore Universal.Operators.StrictComparisons -- Intentional cross-type numeric comparison, like in SQLite. + return null; + } + if ( is_int( $ln ) && is_int( $rn ) ) { + return intdiv( $ln, $rn ); + } + return $ln / $rn; + case '%': + if ( 0 == $rn ) { // phpcs:ignore Universal.Operators.StrictComparisons -- Intentional cross-type numeric comparison, like in SQLite. + return null; + } + if ( is_int( $ln ) && is_int( $rn ) ) { + return $ln % $rn; + } + return fmod( (float) $ln, (float) $rn ); + case '&': + return ( (int) $ln ) & ( (int) $rn ); + case '|': + return ( (int) $ln ) | ( (int) $rn ); + case '<<': + return ( (int) $ln ) << ( (int) $rn ); + case '>>': + return ( (int) $ln ) >> ( (int) $rn ); + } + throw new WP_PHP_Engine_SQL_Exception( 'unsupported operator: ' . $op ); + } + + /** + * Evaluate an IN expression. + * + * @param array $expr The expression. + * @param array $frame The current frame. + * @return mixed The value. + */ + private function eval_in( $expr, $frame ) { + $value = $this->eval( $expr['e'], $frame ); + + $candidates = array(); + if ( isset( $expr['sub'] ) ) { + $result = $this->run_subquery( $expr['sub'], $frame ); + foreach ( $result['rows'] as $row ) { + $candidates[] = $row[0]; + } + } else { + foreach ( $expr['list'] as $item ) { + $candidates[] = $this->eval( $item, $frame ); + } + } + + if ( null === $value ) { + return null; + } + + $affinity = $this->expr_affinity( $expr['e'], $frame ); + $collation = $this->expr_collation( $expr['e'], $frame ); + $has_null = false; + foreach ( $candidates as $candidate ) { + if ( null === $candidate ) { + $has_null = true; + continue; + } + list( $a, $b ) = WP_PHP_Engine_Values::apply_comparison_affinity( $value, $candidate, $affinity, null ); + if ( 0 === WP_PHP_Engine_Values::compare( $a, $b, $collation ) ) { + return $expr['not'] ? 0 : 1; + } + } + if ( $has_null ) { + return null; + } + return $expr['not'] ? 1 : 0; + } + + /** + * Evaluate a LIKE/GLOB/REGEXP/MATCH expression. + * + * @param array $expr The expression. + * @param array $frame The current frame. + * @return mixed The value. + */ + private function eval_like( $expr, $frame ) { + $value = $this->eval( $expr['e'], $frame ); + $pattern = $this->eval( $expr['p'], $frame ); + if ( null === $value || null === $pattern ) { + return null; + } + + switch ( $expr['op'] ) { + case 'LIKE': + $escape = null; + if ( null !== $expr['escape'] ) { + $escape = WP_PHP_Engine_Values::to_text( $this->eval( $expr['escape'], $frame ) ); + } + // A user-defined like() overrides the built-in behavior. + if ( $this->engine->has_user_function( 'like' ) ) { + $args = array( $pattern, $value ); + if ( null !== $escape ) { + $args[] = $escape; + } + $matched = WP_PHP_Engine_Values::is_truthy( $this->engine->call_user_function( 'like', $args ) ); + } else { + $matched = WP_PHP_Engine_Values::like_match( + WP_PHP_Engine_Values::to_text( $pattern ), + WP_PHP_Engine_Values::to_text( $value ), + $escape + ); + } + break; + case 'GLOB': + $matched = WP_PHP_Engine_Values::glob_match( + WP_PHP_Engine_Values::to_text( $pattern ), + WP_PHP_Engine_Values::to_text( $value ) + ); + break; + case 'REGEXP': + case 'MATCH': + $fn = strtolower( $expr['op'] ); + if ( ! $this->engine->has_user_function( $fn ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'unable to use function ' . $expr['op'] . ' in the requested context' ); + } + $result = $this->engine->call_user_function( $fn, array( $pattern, $value ) ); + if ( null === $result ) { + return null; + } + $matched = WP_PHP_Engine_Values::is_truthy( $result ); + break; + default: + throw new WP_PHP_Engine_SQL_Exception( 'unsupported operator: ' . $expr['op'] ); + } + + if ( $expr['not'] ) { + return $matched ? 0 : 1; + } + return $matched ? 1 : 0; + } + + /** + * Evaluate a function call expression. + * + * @param array $expr The expression. + * @param array $frame The current frame. + * @return mixed The value. + */ + private function eval_function( $expr, $frame ) { + $name = $expr['name']; + + // Aggregates outside of a group context operate per-row in SQLite + // only via implicit grouping, which is handled in select_core(). + // Reaching this point with an aggregate is an error, except for + // min/max which double as scalar functions. + $args = array(); + foreach ( $expr['args'] as $arg ) { + $args[] = $this->eval( $arg, $frame ); + } + + // User-defined functions override built-ins. + if ( $this->engine->has_user_function( $name ) ) { + return $this->engine->call_user_function( $name, $args ); + } + + if ( WP_PHP_Engine_Functions::is_scalar( $name ) ) { + return $this->engine->get_functions()->call( $name, $args ); + } + + // Engine-state functions. + switch ( $name ) { + case 'changes': + return $this->engine->get_changes(); + case 'total_changes': + return $this->engine->get_total_changes(); + } + + throw new WP_PHP_Engine_SQL_Exception( 'no such function: ' . $expr['name'] ); + } + + /* + * ---------------------------------------------------------------------- + * Helpers. + * ---------------------------------------------------------------------- + */ + + /** + * Build a type-tagged key for a value (for DISTINCT and grouping). + * + * @param mixed $value The value. + * @return string The key. + */ + public static function value_key( $value ) { + if ( null === $value ) { + return 'n'; + } + if ( $value instanceof WP_PHP_Engine_Blob ) { + return 'b:' . $value->bytes; + } + if ( is_int( $value ) || is_float( $value ) ) { + // Integers and floats with the same value compare equal. + return 'd:' . (float) $value; + } + return 's:' . $value; + } + + /** + * Build a key for a full row. + * + * @param array $row The row values. + * @return string The key. + */ + public static function row_key( $row ) { + $parts = array(); + foreach ( $row as $value ) { + $parts[] = self::value_key( $value ); + } + return implode( '|', $parts ); + } +} + +/** + * An internal exception used to implement RAISE(IGNORE). + */ +class WP_PHP_Engine_Raise_Ignore_Exception extends Exception { +} diff --git a/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-functions.php b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-functions.php new file mode 100644 index 00000000..b17133fe --- /dev/null +++ b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-functions.php @@ -0,0 +1,974 @@ + + */ + public static $scalar_functions = array( + 'abs' => 'abs', + 'char' => 'char', + 'coalesce' => 'coalesce', + 'concat' => 'concat', + 'concat_ws' => 'concat_ws', + 'format' => 'format', + 'glob' => 'glob', + 'hex' => 'hex', + 'ifnull' => 'ifnull', + 'iif' => 'iif', + 'instr' => 'instr', + 'last_insert_rowid' => 'last_insert_rowid', + 'length' => 'length', + 'like' => 'like', + 'likely' => 'identity', + 'lower' => 'lower', + 'ltrim' => 'ltrim', + 'max' => 'max_scalar', + 'min' => 'min_scalar', + 'nullif' => 'nullif', + 'octet_length' => 'octet_length', + 'printf' => 'format', + 'quote' => 'quote', + 'random' => 'random', + 'randomblob' => 'randomblob', + 'replace' => 'replace', + 'round' => 'round', + 'rtrim' => 'rtrim', + 'sign' => 'sign', + 'soundex' => 'soundex', + 'sqlite_version' => 'sqlite_version', + 'sqlite_source_id' => 'sqlite_source_id', + 'substr' => 'substr', + 'substring' => 'substr', + 'trim' => 'trim', + 'typeof' => 'typeof', + 'unhex' => 'unhex', + 'unicode' => 'unicode', + 'unlikely' => 'identity', + 'upper' => 'upper', + 'zeroblob' => 'zeroblob', + 'json_valid' => 'json_valid', + 'json' => 'json', + 'date' => 'date', + 'time' => 'time', + 'datetime' => 'datetime', + 'julianday' => 'julianday', + 'unixepoch' => 'unixepoch', + 'strftime' => 'strftime', + ); + + /** + * Aggregate functions natively understood by the engine. + * + * @var array + */ + public static $aggregate_functions = array( + 'avg' => true, + 'count' => true, + 'group_concat' => true, + 'string_agg' => true, + 'max' => true, + 'min' => true, + 'sum' => true, + 'total' => true, + ); + + /** + * The engine instance (for last_insert_rowid, changes, etc.). + * + * @var WP_PHP_Engine + */ + private $engine; + + /** + * Constructor. + * + * @param WP_PHP_Engine $engine The engine instance. + */ + public function __construct( $engine ) { + $this->engine = $engine; + } + + /** + * Call a built-in scalar function. + * + * @param string $name The lowercase function name. + * @param array $args The argument values. + * @return mixed The function result. + */ + public function call( $name, $args ) { + $method = self::$scalar_functions[ $name ]; + return $this->$method( $args ); + } + + /** + * Check whether a name is a built-in scalar function. + * + * @param string $name The lowercase function name. + * @return bool Whether the function exists. + */ + public static function is_scalar( $name ) { + return isset( self::$scalar_functions[ $name ] ); + } + + /* + * ---------------------------------------------------------------------- + * Core scalar functions. + * ---------------------------------------------------------------------- + */ + + private function identity( $args ) { + return isset( $args[0] ) ? $args[0] : null; + } + + private function abs( $args ) { + $value = $args[0]; + if ( null === $value ) { + return null; + } + $number = WP_PHP_Engine_Values::to_numeric( $value ); + return abs( $number ); + } + + private function char( $args ) { + $result = ''; + foreach ( $args as $arg ) { + if ( null === $arg ) { + continue; + } + $code = (int) $arg; + if ( 0 === $code ) { + $result .= "\x00"; + } elseif ( function_exists( 'mb_chr' ) ) { + $result .= mb_chr( $code, 'UTF-8' ); + } else { + $result .= chr( $code ); + } + } + return $result; + } + + private function coalesce( $args ) { + foreach ( $args as $arg ) { + if ( null !== $arg ) { + return $arg; + } + } + return null; + } + + private function concat( $args ) { + $result = ''; + foreach ( $args as $arg ) { + if ( null !== $arg ) { + $result .= WP_PHP_Engine_Values::to_text( $arg ); + } + } + return $result; + } + + private function concat_ws( $args ) { + $separator = array_shift( $args ); + if ( null === $separator ) { + return null; + } + $parts = array(); + foreach ( $args as $arg ) { + if ( null !== $arg ) { + $parts[] = WP_PHP_Engine_Values::to_text( $arg ); + } + } + return implode( WP_PHP_Engine_Values::to_text( $separator ), $parts ); + } + + private function format( $args ) { + $format = array_shift( $args ); + if ( null === $format ) { + return null; + } + // SQLite's printf is C-like; vsprintf covers the common cases. + $format = str_replace( '%q', '%s', $format ); + $coerced = array(); + foreach ( $args as $arg ) { + $coerced[] = null === $arg ? '' : $arg; + } + return vsprintf( $format, $coerced ); + } + + private function glob( $args ) { + // glob(X, Y): Y is matched against the glob pattern X. + if ( null === $args[0] || null === $args[1] ) { + return null; + } + return WP_PHP_Engine_Values::glob_match( + WP_PHP_Engine_Values::to_text( $args[0] ), + WP_PHP_Engine_Values::to_text( $args[1] ) + ) ? 1 : 0; + } + + private function hex( $args ) { + if ( null === $args[0] ) { + return ''; + } + return strtoupper( bin2hex( WP_PHP_Engine_Values::to_text( $args[0] ) ) ); + } + + private function ifnull( $args ) { + return null !== $args[0] ? $args[0] : $args[1]; + } + + private function iif( $args ) { + $condition = isset( $args[0] ) ? $args[0] : null; + if ( WP_PHP_Engine_Values::is_truthy( $condition ) ) { + return isset( $args[1] ) ? $args[1] : null; + } + return isset( $args[2] ) ? $args[2] : null; + } + + private function instr( $args ) { + if ( null === $args[0] || null === $args[1] ) { + return null; + } + $haystack = WP_PHP_Engine_Values::to_text( $args[0] ); + $needle = WP_PHP_Engine_Values::to_text( $args[1] ); + if ( '' === $needle ) { + return 1; + } + $pos = strpos( $haystack, $needle ); + return false === $pos ? 0 : $pos + 1; + } + + private function last_insert_rowid( $args ) { + return $this->engine->get_last_insert_rowid(); + } + + private function length( $args ) { + $value = $args[0]; + if ( null === $value ) { + return null; + } + if ( is_int( $value ) || is_float( $value ) ) { + return strlen( WP_PHP_Engine_Values::to_text( $value ) ); + } + // For text, the length is in characters. + if ( function_exists( 'mb_strlen' ) ) { + $length = mb_strlen( $value, 'UTF-8' ); + if ( false !== $length ) { + return $length; + } + } + return strlen( $value ); + } + + private function octet_length( $args ) { + if ( null === $args[0] ) { + return null; + } + return strlen( WP_PHP_Engine_Values::to_text( $args[0] ) ); + } + + private function like( $args ) { + // like(X, Y [, Z]): Y is matched against the pattern X. + if ( null === $args[0] || null === $args[1] ) { + return null; + } + $escape = isset( $args[2] ) ? WP_PHP_Engine_Values::to_text( $args[2] ) : null; + return WP_PHP_Engine_Values::like_match( + WP_PHP_Engine_Values::to_text( $args[0] ), + WP_PHP_Engine_Values::to_text( $args[1] ), + $escape + ) ? 1 : 0; + } + + private function lower( $args ) { + if ( null === $args[0] ) { + return null; + } + return strtolower( WP_PHP_Engine_Values::to_text( $args[0] ) ); + } + + private function upper( $args ) { + if ( null === $args[0] ) { + return null; + } + return strtoupper( WP_PHP_Engine_Values::to_text( $args[0] ) ); + } + + private function ltrim( $args ) { + if ( null === $args[0] ) { + return null; + } + $chars = isset( $args[1] ) ? WP_PHP_Engine_Values::to_text( $args[1] ) : " \t\n\r\0\x0B"; + return ltrim( WP_PHP_Engine_Values::to_text( $args[0] ), isset( $args[1] ) ? $chars : ' ' ); + } + + private function rtrim( $args ) { + if ( null === $args[0] ) { + return null; + } + $chars = isset( $args[1] ) ? WP_PHP_Engine_Values::to_text( $args[1] ) : ' '; + return rtrim( WP_PHP_Engine_Values::to_text( $args[0] ), $chars ); + } + + private function trim( $args ) { + if ( null === $args[0] ) { + return null; + } + $chars = isset( $args[1] ) ? WP_PHP_Engine_Values::to_text( $args[1] ) : ' '; + return trim( WP_PHP_Engine_Values::to_text( $args[0] ), $chars ); + } + + private function max_scalar( $args ) { + $max = null; + foreach ( $args as $arg ) { + if ( null === $arg ) { + return null; + } + if ( null === $max || WP_PHP_Engine_Values::compare( $arg, $max ) > 0 ) { + $max = $arg; + } + } + return $max; + } + + private function min_scalar( $args ) { + $min = null; + foreach ( $args as $arg ) { + if ( null === $arg ) { + return null; + } + if ( null === $min || WP_PHP_Engine_Values::compare( $arg, $min ) < 0 ) { + $min = $arg; + } + } + return $min; + } + + private function nullif( $args ) { + if ( 0 === WP_PHP_Engine_Values::compare( $args[0], $args[1] ) && null !== $args[0] && null !== $args[1] ) { + return null; + } + return $args[0]; + } + + private function quote( $args ) { + $value = $args[0]; + if ( null === $value ) { + return 'NULL'; + } + if ( is_int( $value ) || is_float( $value ) ) { + return WP_PHP_Engine_Values::to_text( $value ); + } + return "'" . str_replace( "'", "''", $value ) . "'"; + } + + private function random( $args ) { + return mt_rand( PHP_INT_MIN / 2, PHP_INT_MAX / 2 ); + } + + private function randomblob( $args ) { + $length = max( 1, (int) $args[0] ); + $bytes = ''; + for ( $i = 0; $i < $length; $i++ ) { + $bytes .= chr( mt_rand( 0, 255 ) ); + } + return $bytes; + } + + private function replace( $args ) { + if ( null === $args[0] || null === $args[1] || null === $args[2] ) { + return null; + } + $pattern = WP_PHP_Engine_Values::to_text( $args[1] ); + if ( '' === $pattern ) { + return WP_PHP_Engine_Values::to_text( $args[0] ); + } + return str_replace( + $pattern, + WP_PHP_Engine_Values::to_text( $args[2] ), + WP_PHP_Engine_Values::to_text( $args[0] ) + ); + } + + private function round( $args ) { + if ( null === $args[0] ) { + return null; + } + $value = WP_PHP_Engine_Values::to_numeric( $args[0] ); + $precision = isset( $args[1] ) && null !== $args[1] ? (int) $args[1] : 0; + if ( $precision < 0 ) { + $precision = 0; + } + return round( (float) $value, $precision ); + } + + private function sign( $args ) { + if ( null === $args[0] ) { + return null; + } + $number = WP_PHP_Engine_Values::to_numeric( $args[0] ); + if ( ! is_int( $number ) && ! is_float( $number ) ) { + return null; + } + return $number > 0 ? 1 : ( $number < 0 ? -1 : 0 ); + } + + private function soundex( $args ) { + if ( null === $args[0] ) { + return '?000'; + } + $result = soundex( WP_PHP_Engine_Values::to_text( $args[0] ) ); + return '' === $result ? '?000' : $result; + } + + private function sqlite_version( $args ) { + return WP_PHP_Engine::SQLITE_VERSION; + } + + private function sqlite_source_id( $args ) { + return 'wp-php-engine ' . WP_PHP_Engine::SQLITE_VERSION; + } + + private function substr( $args ) { + if ( null === $args[0] || null === $args[1] ) { + return null; + } + if ( array_key_exists( 2, $args ) && null === $args[2] ) { + return null; + } + $text = WP_PHP_Engine_Values::to_text( $args[0] ); + $start = (int) $args[1]; + $length = isset( $args[2] ) ? (int) $args[2] : null; + + $text_length = function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text ); + + // SQLite substr() uses 1-based indexing with special negative handling. + if ( $start < 0 ) { + $start = $text_length + $start + 1; + if ( $start < 1 ) { + if ( null !== $length ) { + $length += $start - 1; + } + $start = 1; + } + } elseif ( 0 === $start ) { + if ( null !== $length ) { + $length -= 1; + } + $start = 1; + } + if ( null !== $length && $length < 0 ) { + // Negative length means characters before the start position. + $new_start = $start + $length; + $length = -$length; + if ( $new_start < 1 ) { + $length += $new_start - 1; + $new_start = 1; + } + $start = $new_start; + } + if ( null !== $length && $length <= 0 ) { + return ''; + } + if ( function_exists( 'mb_substr' ) ) { + return null === $length + ? mb_substr( $text, $start - 1, null, 'UTF-8' ) + : mb_substr( $text, $start - 1, $length, 'UTF-8' ); + } + return null === $length ? substr( $text, $start - 1 ) : substr( $text, $start - 1, $length ); + } + + private function typeof( $args ) { + return WP_PHP_Engine_Values::type_of( $args[0] ); + } + + private function unhex( $args ) { + if ( null === $args[0] ) { + return null; + } + $hex = WP_PHP_Engine_Values::to_text( $args[0] ); + if ( ! ctype_xdigit( $hex ) || 0 !== strlen( $hex ) % 2 ) { + return null; + } + return pack( 'H*', $hex ); + } + + private function unicode( $args ) { + if ( null === $args[0] || '' === $args[0] ) { + return null; + } + $text = WP_PHP_Engine_Values::to_text( $args[0] ); + if ( function_exists( 'mb_ord' ) ) { + $char = mb_substr( $text, 0, 1, 'UTF-8' ); + $ord = mb_ord( $char, 'UTF-8' ); + if ( false !== $ord ) { + return $ord; + } + } + return ord( $text ); + } + + private function zeroblob( $args ) { + return str_repeat( "\0", max( 0, (int) $args[0] ) ); + } + + private function json_valid( $args ) { + if ( null === $args[0] ) { + return null; + } + json_decode( WP_PHP_Engine_Values::to_text( $args[0] ) ); + return JSON_ERROR_NONE === json_last_error() ? 1 : 0; + } + + private function json( $args ) { + if ( null === $args[0] ) { + return null; + } + $decoded = json_decode( WP_PHP_Engine_Values::to_text( $args[0] ) ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + throw new WP_PHP_Engine_SQL_Exception( 'malformed JSON' ); + } + return json_encode( $decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); + } + + /* + * ---------------------------------------------------------------------- + * Date and time functions. + * + * SQLite represents date-time internally as a Julian day number. We use + * a float Julian day for computation and render to the requested format. + * ---------------------------------------------------------------------- + */ + + const JULIAN_EPOCH_UNIX = 2440587.5; // Julian day of 1970-01-01 00:00:00 UTC. + + private function date( $args ) { + $julian = $this->parse_datetime_args( $args ); + if ( null === $julian ) { + return null; + } + return $this->render_strftime( '%Y-%m-%d', $julian ); + } + + private function time( $args ) { + $julian = $this->parse_datetime_args( $args ); + if ( null === $julian ) { + return null; + } + return $this->render_strftime( '%H:%M:%S', $julian ); + } + + private function datetime( $args ) { + $julian = $this->parse_datetime_args( $args ); + if ( null === $julian ) { + return null; + } + return $this->render_strftime( '%Y-%m-%d %H:%M:%S', $julian ); + } + + private function julianday( $args ) { + return $this->parse_datetime_args( $args ); + } + + private function unixepoch( $args ) { + $julian = $this->parse_datetime_args( $args ); + if ( null === $julian ) { + return null; + } + return (int) round( ( $julian - self::JULIAN_EPOCH_UNIX ) * 86400 ); + } + + private function strftime( $args ) { + $format = array_shift( $args ); + if ( null === $format ) { + return null; + } + $julian = $this->parse_datetime_args( $args ); + if ( null === $julian ) { + return null; + } + return $this->render_strftime( WP_PHP_Engine_Values::to_text( $format ), $julian ); + } + + /** + * Parse date-time arguments (a time value plus modifiers) to a Julian day. + * + * @param array $args The time value and modifiers. + * @return float|null The Julian day number, or null for invalid input. + */ + private function parse_datetime_args( $args ) { + $value = count( $args ) > 0 ? array_shift( $args ) : 'now'; + $julian = $this->parse_time_value( $value ); + if ( null === $julian ) { + return null; + } + foreach ( $args as $modifier ) { + if ( null === $modifier ) { + return null; + } + $julian = $this->apply_modifier( $julian, strtolower( trim( WP_PHP_Engine_Values::to_text( $modifier ) ) ), $value ); + if ( null === $julian ) { + return null; + } + } + return $julian; + } + + /** + * Parse a time value into a Julian day number. + * + * @param mixed $value The time value. + * @return float|null The Julian day number, or null for invalid input. + */ + private function parse_time_value( $value ) { + if ( null === $value ) { + return null; + } + if ( is_int( $value ) || is_float( $value ) ) { + // A numeric value is a Julian day number. + return (float) $value; + } + + $text = trim( (string) $value ); + if ( 'now' === strtolower( $text ) ) { + return self::JULIAN_EPOCH_UNIX + microtime( true ) / 86400; + } + + // Plain numeric strings are Julian day numbers. + if ( is_numeric( $text ) ) { + return (float) $text; + } + + // ISO-8601 formats: YYYY-MM-DD [HH:MM[:SS[.SSS]]] with optional T. + if ( preg_match( + '/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?(?:Z|([+-]\d{2}):(\d{2}))?$/', + $text, + $m + ) ) { + $year = (int) $m[1]; + $month = (int) $m[2]; + $day = (int) $m[3]; + $hour = isset( $m[4] ) && '' !== $m[4] ? (int) $m[4] : 0; + $minute = isset( $m[5] ) && '' !== $m[5] ? (int) $m[5] : 0; + $second = isset( $m[6] ) && '' !== $m[6] ? (float) ( $m[6] . ( isset( $m[7] ) && '' !== $m[7] ? '.' . $m[7] : '' ) ) : 0.0; + + if ( $month < 1 || $month > 12 || $day < 1 || $day > 31 || $hour > 24 || $minute > 59 || $second >= 62 ) { + return null; + } + if ( ! checkdate( $month, $day, $year ) ) { + return null; + } + + $julian = self::gregorian_to_julian_day( $year, $month, $day ) + + ( $hour - 12 ) / 24.0 + $minute / 1440.0 + $second / 86400.0 + 0.5; + + // Apply an explicit timezone offset. + if ( isset( $m[8] ) && '' !== $m[8] ) { + $offset_minutes = (int) $m[8] * 60 + ( (int) $m[8] < 0 ? - (int) $m[9] : (int) $m[9] ); + $julian -= $offset_minutes / 1440.0; + } + return $julian; + } + + // Time-only formats: HH:MM[:SS[.SSS]] (date 2000-01-01). + if ( preg_match( '/^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/', $text, $m ) ) { + $hour = (int) $m[1]; + $minute = (int) $m[2]; + $second = isset( $m[3] ) && '' !== $m[3] ? (float) ( $m[3] . ( isset( $m[4] ) && '' !== $m[4] ? '.' . $m[4] : '' ) ) : 0.0; + if ( $hour > 24 || $minute > 59 || $second >= 62 ) { + return null; + } + return self::gregorian_to_julian_day( 2000, 1, 1 ) + + ( $hour - 12 ) / 24.0 + $minute / 1440.0 + $second / 86400.0 + 0.5; + } + + return null; + } + + /** + * Apply a date-time modifier to a Julian day number. + * + * @param float $julian The Julian day number. + * @param string $modifier The modifier (lowercased and trimmed). + * @param mixed $original_value The original time value (for 'unixepoch'). + * @return float|null The new Julian day number, or null on error. + */ + private function apply_modifier( $julian, $modifier, $original_value ) { + // "unixepoch": reinterpret the numeric value as a Unix timestamp. + if ( 'unixepoch' === $modifier ) { + return self::JULIAN_EPOCH_UNIX + WP_PHP_Engine_Values::to_numeric( $original_value ) / 86400.0; + } + if ( 'julianday' === $modifier || 'auto' === $modifier ) { + return $julian; + } + if ( 'localtime' === $modifier ) { + $timestamp = ( $julian - self::JULIAN_EPOCH_UNIX ) * 86400; + // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- The local timezone is intentional here. + $offset = (int) date( 'Z', (int) round( $timestamp ) ); + return $julian + $offset / 86400.0; + } + if ( 'utc' === $modifier ) { + $timestamp = ( $julian - self::JULIAN_EPOCH_UNIX ) * 86400; + // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- The local timezone is intentional here. + $offset = (int) date( 'Z', (int) round( $timestamp ) ); + return $julian - $offset / 86400.0; + } + + // "start of day/month/year". + if ( 0 === strpos( $modifier, 'start of ' ) ) { + $what = substr( $modifier, 9 ); + list( $year, $month, $day ) = self::julian_day_to_gregorian( $julian ); + if ( 'day' === $what ) { + return self::gregorian_to_julian_day( $year, $month, $day ) - 0.5 + 0.5; + } + if ( 'month' === $what ) { + return self::gregorian_to_julian_day( $year, $month, 1 ); + } + if ( 'year' === $what ) { + return self::gregorian_to_julian_day( $year, 1, 1 ); + } + return null; + } + + // "weekday N". + if ( 0 === strpos( $modifier, 'weekday ' ) ) { + $target = (int) substr( $modifier, 8 ); + $current = (int) $this->render_strftime( '%w', $julian ); + $delta = ( $target - $current + 7 ) % 7; + return $julian + $delta; + } + + // "±NNN units" or "NNN units". + if ( preg_match( '/^([+-]?\d+(?:\.\d+)?)\s*(year|month|day|hour|minute|second)s?$/', $modifier, $m ) ) { + $amount = (float) $m[1]; + $unit = $m[2]; + switch ( $unit ) { + case 'second': + return $julian + $amount / 86400.0; + case 'minute': + return $julian + $amount / 1440.0; + case 'hour': + return $julian + $amount / 24.0; + case 'day': + return $julian + $amount; + case 'month': + case 'year': + list( $year, $month, $day ) = self::julian_day_to_gregorian( $julian ); + $time_fraction = $julian - self::gregorian_to_julian_day( $year, $month, $day ); + $months = 'year' === $unit ? (int) $amount * 12 : (int) $amount; + $total = $year * 12 + ( $month - 1 ) + $months; + $new_year = intdiv( $total, 12 ); + $new_month = $total % 12 + 1; + if ( $new_month < 1 ) { + $new_month += 12; + $new_year -= 1; + } + // Clamp-and-normalize like SQLite (e.g. Jan 31 + 1 month = Mar 3). + $max_day = (int) gmdate( 't', gmmktime( 12, 0, 0, $new_month, 1, $new_year ) ); + $overflow = 0; + $new_day = $day; + if ( $day > $max_day ) { + $overflow = $day - $max_day; + $new_day = $max_day; + } + $result = self::gregorian_to_julian_day( $new_year, $new_month, $new_day ) + $overflow + $time_fraction; + // Fractional months/years are applied as fractions of the unit. + $fraction = $amount - (int) $amount; + if ( 0.0 !== $fraction ) { + $result += $fraction * ( 'year' === $unit ? 365.0 : 30.0 ); + } + return $result; + } + } + + // Bare "±HH:MM[:SS]" time offsets. + if ( preg_match( '/^([+-])(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $modifier, $m ) ) { + $sign = '-' === $m[1] ? -1 : 1; + $seconds = (int) $m[2] * 3600 + (int) $m[3] * 60 + ( isset( $m[4] ) ? (int) $m[4] : 0 ); + return $julian + $sign * $seconds / 86400.0; + } + + return null; + } + + /** + * Render a Julian day number using an strftime-style format. + * + * @param string $format The format string. + * @param float $julian The Julian day number. + * @return string The formatted value. + */ + private function render_strftime( $format, $julian ) { + list( $year, $month, $day ) = self::julian_day_to_gregorian( $julian ); + + $day_fraction = $julian + 0.5; + $day_fraction = $day_fraction - floor( $day_fraction ); + $total_seconds = $day_fraction * 86400.0; + // Round to milliseconds to avoid floating point drift. + $total_seconds = round( $total_seconds, 3 ); + if ( $total_seconds >= 86400.0 ) { + $total_seconds = 0.0; + // The rounding pushed us to the next day. + list( $year, $month, $day ) = self::julian_day_to_gregorian( $julian + 0.000001 ); + } + $hour = (int) floor( $total_seconds / 3600 ); + $minute = (int) floor( fmod( $total_seconds, 3600 ) / 60 ); + $second = fmod( $total_seconds, 60 ); + $int_sec = (int) floor( $second ); + + $timestamp = (int) round( ( $julian - self::JULIAN_EPOCH_UNIX ) * 86400 ); + $weekday = (int) floor( fmod( $julian + 1.5, 7 ) ); // 0 = Sunday. + if ( $weekday < 0 ) { + $weekday += 7; + } + + $day_of_year = (int) ( self::gregorian_to_julian_day( $year, $month, $day ) - self::gregorian_to_julian_day( $year, 1, 1 ) ) + 1; + + $result = ''; + $length = strlen( $format ); + for ( $i = 0; $i < $length; $i++ ) { + $char = $format[ $i ]; + if ( '%' !== $char || $i + 1 >= $length ) { + $result .= $char; + continue; + } + $i += 1; + switch ( $format[ $i ] ) { + case 'd': + $result .= sprintf( '%02d', $day ); + break; + case 'e': + $result .= sprintf( '%2d', $day ); + break; + case 'f': + $result .= sprintf( '%06.3f', $second ); + break; + case 'F': + $result .= sprintf( '%04d-%02d-%02d', $year, $month, $day ); + break; + case 'H': + $result .= sprintf( '%02d', $hour ); + break; + case 'I': + $hour12 = $hour % 12; + $result .= sprintf( '%02d', 0 === $hour12 ? 12 : $hour12 ); + break; + case 'j': + $result .= sprintf( '%03d', $day_of_year ); + break; + case 'J': + $result .= rtrim( rtrim( sprintf( '%.8f', $julian ), '0' ), '.' ); + break; + case 'k': + $result .= sprintf( '%2d', $hour ); + break; + case 'l': + $hour12 = $hour % 12; + $result .= sprintf( '%2d', 0 === $hour12 ? 12 : $hour12 ); + break; + case 'm': + $result .= sprintf( '%02d', $month ); + break; + case 'M': + $result .= sprintf( '%02d', $minute ); + break; + case 'p': + $result .= $hour >= 12 ? 'PM' : 'AM'; + break; + case 'P': + $result .= $hour >= 12 ? 'pm' : 'am'; + break; + case 'R': + $result .= sprintf( '%02d:%02d', $hour, $minute ); + break; + case 's': + $result .= (string) $timestamp; + break; + case 'S': + $result .= sprintf( '%02d', $int_sec ); + break; + case 'T': + $result .= sprintf( '%02d:%02d:%02d', $hour, $minute, $int_sec ); + break; + case 'u': + $result .= (string) ( 0 === $weekday ? 7 : $weekday ); + break; + case 'w': + $result .= (string) $weekday; + break; + case 'W': + // Week of year: Monday as the first day of the week. + $jan1_weekday = (int) floor( fmod( self::gregorian_to_julian_day( $year, 1, 1 ) + 1.5, 7 ) ); + $offset = ( $jan1_weekday + 6 ) % 7; // Days since Monday. + $result .= sprintf( '%02d', (int) ( ( $day_of_year + $offset - 1 ) / 7 ) ); + break; + case 'Y': + $result .= sprintf( '%04d', $year ); + break; + case 'G': + $result .= sprintf( '%04d', (int) gmdate( 'o', $timestamp ) ); + break; + case 'V': + $result .= gmdate( 'W', $timestamp ); + break; + case '%': + $result .= '%'; + break; + default: + $result .= '%' . $format[ $i ]; + break; + } + } + return $result; + } + + /** + * Convert a Gregorian date to a Julian day number (at noon). + * + * @param int $year The year. + * @param int $month The month. + * @param int $day The day. + * @return float The Julian day number at 00:00 of the date... shifted so + * that adding time fractions works (value at midnight). + */ + private static function gregorian_to_julian_day( $year, $month, $day ) { + $a = intdiv( 14 - $month, 12 ); + $y = $year + 4800 - $a; + $m = $month + 12 * $a - 3; + $jdn = $day + intdiv( 153 * $m + 2, 5 ) + 365 * $y + intdiv( $y, 4 ) - intdiv( $y, 100 ) + intdiv( $y, 400 ) - 32045; + return $jdn - 0.5; // Midnight at the start of the date. + } + + /** + * Convert a Julian day number to a Gregorian date. + * + * @param float $julian The Julian day number. + * @return array The year, month, and day. + */ + private static function julian_day_to_gregorian( $julian ) { + $jdn = (int) floor( $julian + 0.5 ); + $a = $jdn + 32044; + $b = intdiv( 4 * $a + 3, 146097 ); + $c = $a - intdiv( 146097 * $b, 4 ); + $d = intdiv( 4 * $c + 3, 1461 ); + $e = $c - intdiv( 1461 * $d, 4 ); + $m = intdiv( 5 * $e + 2, 153 ); + + $day = $e - intdiv( 153 * $m + 2, 5 ) + 1; + $month = $m + 3 - 12 * intdiv( $m, 10 ); + $year = 100 * $b + $d - 4800 + intdiv( $m, 10 ); + return array( $year, $month, $day ); + } +} diff --git a/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-lexer.php b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-lexer.php new file mode 100644 index 00000000..abfcba7c --- /dev/null +++ b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-lexer.php @@ -0,0 +1,378 @@ + + */ + private static $keywords = array( + 'ABORT' => true, + 'ACTION' => true, + 'ADD' => true, + 'AFTER' => true, + 'ALL' => true, + 'ALTER' => true, + 'ANALYZE' => true, + 'AND' => true, + 'AS' => true, + 'ASC' => true, + 'AUTOINCREMENT' => true, + 'BEFORE' => true, + 'BEGIN' => true, + 'BETWEEN' => true, + 'BY' => true, + 'CASCADE' => true, + 'CASE' => true, + 'CAST' => true, + 'CHECK' => true, + 'COLLATE' => true, + 'COLUMN' => true, + 'COMMIT' => true, + 'CONFLICT' => true, + 'CONSTRAINT' => true, + 'CREATE' => true, + 'CROSS' => true, + 'CURRENT_DATE' => true, + 'CURRENT_TIME' => true, + 'CURRENT_TIMESTAMP' => true, + 'DEFAULT' => true, + 'DEFERRABLE' => true, + 'DEFERRED' => true, + 'DELETE' => true, + 'DESC' => true, + 'DISTINCT' => true, + 'DO' => true, + 'DROP' => true, + 'EACH' => true, + 'ELSE' => true, + 'END' => true, + 'ESCAPE' => true, + 'EXCEPT' => true, + 'EXCLUSIVE' => true, + 'EXISTS' => true, + 'FALSE' => true, + 'FILTER' => true, + 'FOR' => true, + 'FOREIGN' => true, + 'FROM' => true, + 'FULL' => true, + 'GLOB' => true, + 'GROUP' => true, + 'HAVING' => true, + 'IF' => true, + 'IGNORE' => true, + 'IMMEDIATE' => true, + 'IN' => true, + 'INDEX' => true, + 'INDEXED' => true, + 'INITIALLY' => true, + 'INNER' => true, + 'INSERT' => true, + 'INTERSECT' => true, + 'INTO' => true, + 'IS' => true, + 'ISNULL' => true, + 'JOIN' => true, + 'KEY' => true, + 'LEFT' => true, + 'LIKE' => true, + 'LIMIT' => true, + 'MATCH' => true, + 'NATURAL' => true, + 'NO' => true, + 'NOT' => true, + 'NOTHING' => true, + 'NOTNULL' => true, + 'NULL' => true, + 'OF' => true, + 'OFFSET' => true, + 'ON' => true, + 'OR' => true, + 'ORDER' => true, + 'OUTER' => true, + 'OVER' => true, + 'PARTITION' => true, + 'PRAGMA' => true, + 'PRIMARY' => true, + 'RAISE' => true, + 'RECURSIVE' => true, + 'REFERENCES' => true, + 'REGEXP' => true, + 'REINDEX' => true, + 'RELEASE' => true, + 'RENAME' => true, + 'REPLACE' => true, + 'RESTRICT' => true, + 'RIGHT' => true, + 'ROLLBACK' => true, + 'ROW' => true, + 'ROWS' => true, + 'SAVEPOINT' => true, + 'SELECT' => true, + 'SET' => true, + 'STRICT' => true, + 'TABLE' => true, + 'TEMP' => true, + 'TEMPORARY' => true, + 'THEN' => true, + 'TO' => true, + 'TRANSACTION' => true, + 'TRIGGER' => true, + 'TRUE' => true, + 'UNION' => true, + 'UNIQUE' => true, + 'UPDATE' => true, + 'USING' => true, + 'VACUUM' => true, + 'VALUES' => true, + 'VIEW' => true, + 'VIRTUAL' => true, + 'WHEN' => true, + 'WHERE' => true, + 'WINDOW' => true, + 'WITH' => true, + 'WITHOUT' => true, + ); + + /** + * Tokenize an SQL string. + * + * Each token is a tuple: array( type, normalized-value, raw-value ). + * For keywords, the normalized value is the uppercase keyword. + * For identifiers, the normalized value is the unquoted identifier. + * For strings/blobs, the normalized value is the decoded value. + * For numbers, the normalized value is an int or a float. + * For parameters, the normalized value is the parameter name or null + * for plain positional "?" parameters. + * + * @param string $sql The SQL string to tokenize. + * @return array The list of tokens. + * @throws WP_PHP_Engine_SQL_Exception When the input cannot be tokenized. + */ + public static function tokenize( $sql ) { + $tokens = array(); + $length = strlen( $sql ); + $i = 0; + + while ( $i < $length ) { + $char = $sql[ $i ]; + + // Skip whitespace. + if ( ' ' === $char || "\t" === $char || "\n" === $char || "\r" === $char || "\v" === $char || "\f" === $char ) { + $i += 1; + continue; + } + + // Skip line comments. + if ( '-' === $char && $i + 1 < $length && '-' === $sql[ $i + 1 ] ) { + $end = strpos( $sql, "\n", $i ); + $i = false === $end ? $length : $end + 1; + continue; + } + + // Skip block comments. + if ( '/' === $char && $i + 1 < $length && '*' === $sql[ $i + 1 ] ) { + $end = strpos( $sql, '*/', $i + 2 ); + $i = false === $end ? $length : $end + 2; + continue; + } + + // String literals. + if ( "'" === $char ) { + $start = $i; + $value = self::read_quoted( $sql, $i, "'" ); + $tokens[] = array( self::TYPE_STRING, $value, $start, $i ); + continue; + } + + // Quoted identifiers. + if ( '`' === $char || '"' === $char ) { + $start = $i; + $value = self::read_quoted( $sql, $i, $char ); + $tokens[] = array( self::TYPE_IDENTIFIER, $value, $start, $i ); + continue; + } + if ( '[' === $char ) { + $end = strpos( $sql, ']', $i + 1 ); + if ( false === $end ) { + throw new WP_PHP_Engine_SQL_Exception( 'unrecognized token: "["' ); + } + $tokens[] = array( self::TYPE_IDENTIFIER, substr( $sql, $i + 1, $end - $i - 1 ), $i, $end + 1 ); + $i = $end + 1; + continue; + } + + // Numbers (and the ".5" form). + if ( ( $char >= '0' && $char <= '9' ) || ( '.' === $char && $i + 1 < $length && $sql[ $i + 1 ] >= '0' && $sql[ $i + 1 ] <= '9' ) ) { + // Hex literals. + if ( '0' === $char && $i + 1 < $length && ( 'x' === $sql[ $i + 1 ] || 'X' === $sql[ $i + 1 ] ) ) { + $j = $i + 2; + while ( $j < $length && ctype_xdigit( $sql[ $j ] ) ) { + $j += 1; + } + $tokens[] = array( self::TYPE_NUMBER, hexdec( substr( $sql, $i + 2, $j - $i - 2 ) ), $i, $j ); + $i = $j; + continue; + } + $j = $i; + $is_float = false; + while ( $j < $length && $sql[ $j ] >= '0' && $sql[ $j ] <= '9' ) { + $j += 1; + } + if ( $j < $length && '.' === $sql[ $j ] ) { + $is_float = true; + $j += 1; + while ( $j < $length && $sql[ $j ] >= '0' && $sql[ $j ] <= '9' ) { + $j += 1; + } + } + if ( $j < $length && ( 'e' === $sql[ $j ] || 'E' === $sql[ $j ] ) ) { + $k = $j + 1; + if ( $k < $length && ( '+' === $sql[ $k ] || '-' === $sql[ $k ] ) ) { + $k += 1; + } + if ( $k < $length && $sql[ $k ] >= '0' && $sql[ $k ] <= '9' ) { + $is_float = true; + $j = $k; + while ( $j < $length && $sql[ $j ] >= '0' && $sql[ $j ] <= '9' ) { + $j += 1; + } + } + } + $raw = substr( $sql, $i, $j - $i ); + if ( $is_float ) { + $value = (float) $raw; + } else { + // Integers that overflow PHP int become floats (like SQLite REALs). + $value = (float) (int) $raw == (float) $raw ? (int) $raw : (float) $raw; // phpcs:ignore Universal.Operators.StrictComparisons -- Intentional cross-type numeric comparison, like in SQLite. + } + $tokens[] = array( self::TYPE_NUMBER, $value, $i, $j ); + $i = $j; + continue; + } + + // Blob literals and identifiers/keywords. + if ( ctype_alpha( $char ) || '_' === $char ) { + if ( ( 'x' === $char || 'X' === $char ) && $i + 1 < $length && "'" === $sql[ $i + 1 ] ) { + $start = $i; + $i += 1; + $value = self::read_quoted( $sql, $i, "'" ); + $tokens[] = array( self::TYPE_BLOB, pack( 'H*', $value ), $start, $i ); + continue; + } + $j = $i + 1; + while ( $j < $length && ( ctype_alnum( $sql[ $j ] ) || '_' === $sql[ $j ] || '$' === $sql[ $j ] ) ) { + $j += 1; + } + $word = substr( $sql, $i, $j - $i ); + $upper = strtoupper( $word ); + if ( isset( self::$keywords[ $upper ] ) ) { + $tokens[] = array( self::TYPE_KEYWORD, $upper, $i, $j ); + } else { + $tokens[] = array( self::TYPE_IDENTIFIER, $word, $i, $j ); + } + $i = $j; + continue; + } + + // Parameters. + if ( '?' === $char ) { + $j = $i + 1; + while ( $j < $length && $sql[ $j ] >= '0' && $sql[ $j ] <= '9' ) { + $j += 1; + } + $name = $j > $i + 1 ? substr( $sql, $i + 1, $j - $i - 1 ) : null; + $tokens[] = array( self::TYPE_PARAMETER, $name, $i, $j ); + $i = $j; + continue; + } + if ( ':' === $char || '@' === $char || '$' === $char ) { + $j = $i + 1; + while ( $j < $length && ( ctype_alnum( $sql[ $j ] ) || '_' === $sql[ $j ] ) ) { + $j += 1; + } + if ( $j === $i + 1 ) { + throw new WP_PHP_Engine_SQL_Exception( sprintf( 'unrecognized token: "%s"', $char ) ); + } + $tokens[] = array( self::TYPE_PARAMETER, substr( $sql, $i, $j - $i ), $i, $j ); + $i = $j; + continue; + } + + // Operators. + $two = $i + 1 < $length ? substr( $sql, $i, 2 ) : ''; + if ( '||' === $two || '<<' === $two || '>>' === $two || '<=' === $two || '>=' === $two || '==' === $two || '!=' === $two || '<>' === $two ) { + $tokens[] = array( self::TYPE_OPERATOR, $two, $i, $i + 2 ); + $i += 2; + continue; + } + if ( false !== strpos( '()+-*/%,;=<>&|~.', $char ) ) { + $tokens[] = array( self::TYPE_OPERATOR, $char, $i, $i + 1 ); + $i += 1; + continue; + } + + throw new WP_PHP_Engine_SQL_Exception( sprintf( 'unrecognized token: "%s"', $char ) ); + } + + return $tokens; + } + + /** + * Read a quoted region with doubled-quote escaping. + * + * @param string $sql The SQL string. + * @param int $i The current position (at the opening quote); updated to after the closing quote. + * @param string $quote The quote character. + * @return string The decoded value. + * @throws WP_PHP_Engine_SQL_Exception When the quoted region is not terminated. + */ + private static function read_quoted( $sql, &$i, $quote ) { + $length = strlen( $sql ); + $value = ''; + $j = $i + 1; + while ( true ) { + $end = strpos( $sql, $quote, $j ); + if ( false === $end ) { + throw new WP_PHP_Engine_SQL_Exception( 'unterminated quoted string' ); + } + $value .= substr( $sql, $j, $end - $j ); + if ( $end + 1 < $length && $sql[ $end + 1 ] === $quote ) { + $value .= $quote; + $j = $end + 2; + continue; + } + $i = $end + 1; + return $value; + } + } +} diff --git a/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-parser.php b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-parser.php new file mode 100644 index 00000000..412fc55f --- /dev/null +++ b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-parser.php @@ -0,0 +1,2428 @@ +sql = $sql; + $this->tokens = WP_PHP_Engine_Lexer::tokenize( $sql ); + $this->pos = 0; + $this->parameter_count = 0; + + $statements = array(); + while ( null !== $this->peek() ) { + if ( $this->try_consume_operator( ';' ) ) { + continue; + } + $statements[] = $this->parse_statement(); + } + return $statements; + } + + /** + * Parse a single statement. + * + * @return array The statement node. + * @throws WP_PHP_Engine_SQL_Exception When the statement cannot be parsed. + */ + private function parse_statement() { + $token = $this->peek(); + if ( self::is_keyword( $token ) ) { + switch ( $token[1] ) { + case 'SELECT': + case 'VALUES': + return $this->parse_select(); + case 'WITH': + return $this->parse_with_statement(); + case 'INSERT': + case 'REPLACE': + return $this->parse_insert(); + case 'UPDATE': + return $this->parse_update( array() ); + case 'DELETE': + return $this->parse_delete( array() ); + case 'CREATE': + return $this->parse_create(); + case 'DROP': + return $this->parse_drop(); + case 'ALTER': + return $this->parse_alter(); + case 'PRAGMA': + return $this->parse_pragma(); + case 'BEGIN': + $this->next(); + $this->try_consume_keywords( array( 'DEFERRED', 'IMMEDIATE', 'EXCLUSIVE' ) ); + $this->try_consume_keyword( 'TRANSACTION' ); + return array( 't' => 'begin' ); + case 'COMMIT': + case 'END': + $this->next(); + $this->try_consume_keyword( 'TRANSACTION' ); + return array( 't' => 'commit' ); + case 'ROLLBACK': + $this->next(); + $this->try_consume_keyword( 'TRANSACTION' ); + if ( $this->try_consume_keyword( 'TO' ) ) { + $this->try_consume_keyword( 'SAVEPOINT' ); + return array( + 't' => 'rollback_to', + 'name' => $this->consume_identifier(), + ); + } + return array( 't' => 'rollback' ); + case 'SAVEPOINT': + $this->next(); + return array( + 't' => 'savepoint', + 'name' => $this->consume_identifier(), + ); + case 'RELEASE': + $this->next(); + $this->try_consume_keyword( 'SAVEPOINT' ); + return array( + 't' => 'release', + 'name' => $this->consume_identifier(), + ); + case 'ANALYZE': + case 'REINDEX': + case 'VACUUM': + $this->next(); + $name = null; + if ( null !== $this->peek() && ! $this->peek_operator( ';' ) ) { + $name = $this->consume_identifier(); + if ( $this->try_consume_operator( '.' ) ) { + $name = $this->consume_identifier(); + } + } + if ( 'ANALYZE' === $token[1] ) { + return array( + 't' => 'analyze', + 'name' => $name, + ); + } + return array( 't' => 'noop' ); + } + } + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $token ) ) + ); + } + + /** + * Parse a statement starting with a WITH clause. + * + * @return array The statement node. + */ + private function parse_with_statement() { + $with = $this->parse_with_clause(); + $token = $this->peek(); + if ( self::is_keyword( $token ) ) { + switch ( $token[1] ) { + case 'SELECT': + case 'VALUES': + return $this->parse_select( $with ); + case 'UPDATE': + return $this->parse_update( $with ); + case 'DELETE': + return $this->parse_delete( $with ); + case 'INSERT': + case 'REPLACE': + $insert = $this->parse_insert(); + $insert['with'] = $with; + return $insert; + } + } + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $token ) ) + ); + } + + /** + * Parse a WITH clause (after the WITH keyword). + * + * @return array A map of CTE name => array( cols, select ). + */ + private function parse_with_clause() { + $this->consume_keyword( 'WITH' ); + $this->try_consume_keyword( 'RECURSIVE' ); + $ctes = array(); + do { + $name = $this->consume_identifier(); + $cols = null; + if ( $this->try_consume_operator( '(' ) ) { + $cols = array(); + do { + $cols[] = $this->consume_identifier(); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + } + $this->consume_keyword( 'AS' ); + $this->try_consume_keyword( 'NOT' ); // NOT MATERIALIZED. + $this->try_consume_identifier_word( 'MATERIALIZED' ); + $this->consume_operator( '(' ); + $select = $this->parse_select(); + $this->consume_operator( ')' ); + $ctes[ strtolower( $name ) ] = array( + 'cols' => $cols, + 'sel' => $select, + ); + } while ( $this->try_consume_operator( ',' ) ); + return $ctes; + } + + /** + * Parse a SELECT statement (or VALUES statement), including compound + * queries, ORDER BY, and LIMIT clauses. + * + * @param array $with Optional CTE map from a preceding WITH clause. + * @return array The select node. + */ + private function parse_select( $with = array() ) { + if ( $this->peek_keyword( 'WITH' ) ) { + $with = $this->parse_with_clause(); + } + + $parts = array( $this->parse_select_core() ); + $ops = array(); + while ( true ) { + $token = $this->peek(); + if ( ! self::is_keyword( $token ) ) { + break; + } + if ( 'UNION' === $token[1] ) { + $this->next(); + $ops[] = $this->try_consume_keyword( 'ALL' ) ? 'UNION ALL' : 'UNION'; + } elseif ( 'EXCEPT' === $token[1] || 'INTERSECT' === $token[1] ) { + $this->next(); + $ops[] = $token[1]; + } else { + break; + } + $parts[] = $this->parse_select_core(); + } + + $order = null; + if ( $this->try_consume_keyword( 'ORDER' ) ) { + $this->consume_keyword( 'BY' ); + $order = $this->parse_order_by_list(); + } + + $limit = null; + $offset = null; + if ( $this->try_consume_keyword( 'LIMIT' ) ) { + $limit = $this->parse_expr(); + if ( $this->try_consume_keyword( 'OFFSET' ) ) { + $offset = $this->parse_expr(); + } elseif ( $this->try_consume_operator( ',' ) ) { + // "LIMIT offset, limit" form. + $offset = $limit; + $limit = $this->parse_expr(); + } + } + + return array( + 't' => 'select', + 'with' => $with, + 'parts' => $parts, + 'ops' => $ops, + 'order' => $order, + 'limit' => $limit, + 'offset' => $offset, + ); + } + + /** + * Parse one core SELECT (or VALUES) body without compound operators. + * + * @return array The select core node. + */ + private function parse_select_core() { + if ( $this->try_consume_keyword( 'VALUES' ) ) { + $rows = array(); + do { + $this->consume_operator( '(' ); + $row = array(); + do { + $row[] = $this->parse_expr(); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + $rows[] = $row; + } while ( $this->try_consume_operator( ',' ) ); + return array( + 't' => 'values', + 'rows' => $rows, + ); + } + + $this->consume_keyword( 'SELECT' ); + $distinct = false; + if ( $this->try_consume_keyword( 'DISTINCT' ) ) { + $distinct = true; + } else { + $this->try_consume_keyword( 'ALL' ); + } + + // Select items. + $items = array(); + do { + if ( $this->try_consume_operator( '*' ) ) { + $items[] = array( + 'star' => true, + 'tbl' => null, + ); + continue; + } + // "table.*" requires lookahead. + $token = $this->peek(); + if ( self::is_identifier( $token ) + && $this->peek_operator_at( 1, '.' ) + && $this->peek_operator_at( 2, '*' ) ) { + $this->next(); + $this->next(); + $this->next(); + $items[] = array( + 'star' => true, + 'tbl' => $token[1], + ); + continue; + } + $expr_start = $this->pos; + $expr = $this->parse_expr(); + $expr_end = $this->pos - 1; + $alias = null; + if ( $this->try_consume_keyword( 'AS' ) ) { + $alias = $this->consume_identifier_or_string(); + } else { + $next = $this->peek(); + if ( self::is_identifier( $next ) || self::is_string( $next ) ) { + $this->next(); + $alias = $next[1]; + } + } + $items[] = array( + 'e' => $expr, + 'alias' => $alias, + 'text' => $this->source_text( $expr_start, $expr_end ), + ); + } while ( $this->try_consume_operator( ',' ) ); + + // FROM clause. + $from = null; + if ( $this->try_consume_keyword( 'FROM' ) ) { + $from = $this->parse_from_clause(); + } + + $where = null; + if ( $this->try_consume_keyword( 'WHERE' ) ) { + $where = $this->parse_expr(); + } + + $group = null; + $having = null; + if ( $this->try_consume_keyword( 'GROUP' ) ) { + $this->consume_keyword( 'BY' ); + $group = array(); + do { + $group[] = $this->parse_expr(); + } while ( $this->try_consume_operator( ',' ) ); + if ( $this->try_consume_keyword( 'HAVING' ) ) { + $having = $this->parse_expr(); + } + } + + return array( + 't' => 'core', + 'distinct' => $distinct, + 'items' => $items, + 'from' => $from, + 'where' => $where, + 'group' => $group, + 'having' => $having, + ); + } + + /** + * Parse a FROM clause (a join tree). + * + * @return array The from-reference node. + */ + private function parse_from_clause() { + $left = $this->parse_table_or_subquery(); + while ( true ) { + $kind = null; + if ( $this->try_consume_operator( ',' ) ) { + $kind = 'CROSS'; + } elseif ( $this->try_consume_keyword( 'CROSS' ) ) { + $this->consume_keyword( 'JOIN' ); + $kind = 'CROSS'; + } elseif ( $this->try_consume_keyword( 'INNER' ) ) { + $this->consume_keyword( 'JOIN' ); + $kind = 'INNER'; + } elseif ( $this->try_consume_keyword( 'JOIN' ) ) { + $kind = 'INNER'; + } elseif ( $this->try_consume_keyword( 'LEFT' ) ) { + $this->try_consume_keyword( 'OUTER' ); + $this->consume_keyword( 'JOIN' ); + $kind = 'LEFT'; + } elseif ( $this->try_consume_keyword( 'RIGHT' ) ) { + $this->try_consume_keyword( 'OUTER' ); + $this->consume_keyword( 'JOIN' ); + $kind = 'RIGHT'; + } else { + break; + } + + $right = $this->parse_table_or_subquery(); + $on = null; + $using = null; + if ( 'CROSS' !== $kind || $this->peek_keyword( 'ON' ) || $this->peek_keyword( 'USING' ) ) { + if ( $this->try_consume_keyword( 'ON' ) ) { + $on = $this->parse_expr(); + } elseif ( $this->try_consume_keyword( 'USING' ) ) { + $this->consume_operator( '(' ); + $using = array(); + do { + $using[] = $this->consume_identifier(); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + } + } + $left = array( + 't' => 'join', + 'kind' => $kind, + 'l' => $left, + 'r' => $right, + 'on' => $on, + 'using' => $using, + ); + } + return $left; + } + + /** + * Parse a single table reference, subquery, VALUES list, or + * table-valued function in a FROM clause. + * + * @return array The table-reference node. + */ + private function parse_table_or_subquery() { + // Parenthesized: subquery, VALUES, or a parenthesized join. + if ( $this->try_consume_operator( '(' ) ) { + if ( $this->peek_keyword( 'SELECT' ) || $this->peek_keyword( 'WITH' ) || $this->peek_keyword( 'VALUES' ) ) { + $select = $this->parse_select(); + $this->consume_operator( ')' ); + $ref = array( + 't' => 'subquery', + 'sel' => $select, + ); + } else { + $ref = $this->parse_from_clause(); + $this->consume_operator( ')' ); + } + $ref['alias'] = $this->parse_table_alias(); + return $ref; + } + + $db = null; + $name = $this->consume_identifier(); + if ( $this->try_consume_operator( '.' ) ) { + $db = $name; + $name = $this->consume_identifier(); + } + + // Table-valued functions, e.g. pragma_table_info(...). + if ( $this->try_consume_operator( '(' ) ) { + $args = array(); + if ( ! $this->peek_operator( ')' ) ) { + do { + $args[] = $this->parse_expr(); + } while ( $this->try_consume_operator( ',' ) ); + } + $this->consume_operator( ')' ); + return array( + 't' => 'tablefn', + 'name' => strtolower( $name ), + 'args' => $args, + 'alias' => $this->parse_table_alias(), + ); + } + + $alias = $this->parse_table_alias(); + if ( $this->try_consume_keyword( 'INDEXED' ) ) { + $this->consume_keyword( 'BY' ); + $this->consume_identifier(); + } elseif ( $this->try_consume_keyword( 'NOT' ) ) { + $this->consume_keyword( 'INDEXED' ); + } + return array( + 't' => 'table', + 'db' => $db, + 'name' => $name, + 'alias' => $alias, + ); + } + + /** + * Parse an optional table alias. + * + * @return string|null The alias, if present. + */ + private function parse_table_alias() { + if ( $this->try_consume_keyword( 'AS' ) ) { + return $this->consume_identifier_or_string(); + } + $token = $this->peek(); + if ( self::is_identifier( $token ) || self::is_string( $token ) ) { + $this->next(); + return $token[1]; + } + return null; + } + + /** + * Parse an ORDER BY item list. + * + * @return array A list of order items: array( e, dir, collate ). + */ + private function parse_order_by_list() { + $order = array(); + do { + $expr = $this->parse_expr(); + $collate = null; + if ( isset( $expr['t'] ) && 'collate' === $expr['t'] ) { + $collate = $expr['name']; + $expr = $expr['e']; + } + $dir = 'ASC'; + if ( $this->try_consume_keyword( 'DESC' ) ) { + $dir = 'DESC'; + } else { + $this->try_consume_keyword( 'ASC' ); + } + // NULLS FIRST/LAST. + if ( $this->try_consume_identifier_word( 'NULLS' ) ) { + $this->next(); + } + $order[] = array( + 'e' => $expr, + 'dir' => $dir, + 'collate' => $collate, + ); + } while ( $this->try_consume_operator( ',' ) ); + return $order; + } + + /** + * Parse an INSERT or REPLACE statement. + * + * @return array The insert node. + */ + private function parse_insert() { + $or = null; + if ( $this->try_consume_keyword( 'REPLACE' ) ) { + $or = 'REPLACE'; + } else { + $this->consume_keyword( 'INSERT' ); + if ( $this->try_consume_keyword( 'OR' ) ) { + $token = $this->next(); + $or = $token[1]; + } + } + $this->consume_keyword( 'INTO' ); + $db = null; + $name = $this->consume_identifier(); + if ( $this->try_consume_operator( '.' ) ) { + $db = strtolower( $name ); + $name = $this->consume_identifier(); + } + $alias = null; + if ( $this->try_consume_keyword( 'AS' ) ) { + $alias = $this->consume_identifier(); + } + + $cols = null; + if ( $this->try_consume_operator( '(' ) ) { + $cols = array(); + do { + $cols[] = $this->consume_identifier(); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + } + + if ( $this->try_consume_keyword( 'DEFAULT' ) ) { + $this->consume_keyword( 'VALUES' ); + $src = array( 't' => 'default_values' ); + } else { + $src = $this->parse_select(); + } + + $upsert = null; + if ( $this->try_consume_keyword( 'ON' ) ) { + $this->consume_keyword( 'CONFLICT' ); + $conflict_cols = null; + if ( $this->try_consume_operator( '(' ) ) { + $conflict_cols = array(); + do { + $conflict_cols[] = $this->consume_identifier(); + // Optional COLLATE and direction in the conflict target. + if ( $this->try_consume_keyword( 'COLLATE' ) ) { + $this->consume_identifier(); + } + $this->try_consume_keywords( array( 'ASC', 'DESC' ) ); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + if ( $this->try_consume_keyword( 'WHERE' ) ) { + $this->parse_expr(); + } + } + $this->consume_keyword( 'DO' ); + if ( $this->try_consume_keyword( 'NOTHING' ) ) { + $upsert = array( + 'cols' => $conflict_cols, + 'do' => 'nothing', + ); + } else { + $this->consume_keyword( 'UPDATE' ); + $this->consume_keyword( 'SET' ); + $set = $this->parse_set_list(); + $where = null; + if ( $this->try_consume_keyword( 'WHERE' ) ) { + $where = $this->parse_expr(); + } + $upsert = array( + 'cols' => $conflict_cols, + 'do' => 'update', + 'set' => $set, + 'where' => $where, + ); + } + } + + return array( + 't' => 'insert', + 'or' => $or, + 'tbl' => $name, + 'db' => $db, + 'alias' => $alias, + 'cols' => $cols, + 'src' => $src, + 'upsert' => $upsert, + 'with' => array(), + ); + } + + /** + * Parse a SET assignment list (for UPDATE and upserts). + * + * @return array A list of assignments: array( cols, e ). + */ + private function parse_set_list() { + $set = array(); + do { + // Multi-column assignment: SET (a, b) = (SELECT ...). + if ( $this->try_consume_operator( '(' ) ) { + $cols = array(); + do { + $cols[] = $this->consume_identifier(); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + $this->consume_operator( '=' ); + $set[] = array( + 'cols' => $cols, + 'e' => $this->parse_expr(), + ); + continue; + } + // Column name, optionally qualified with the table name. + $col = $this->consume_identifier(); + if ( $this->try_consume_operator( '.' ) ) { + $col = $this->consume_identifier(); + } + $this->consume_operator( '=' ); + $set[] = array( + 'col' => $col, + 'e' => $this->parse_expr(), + ); + } while ( $this->try_consume_operator( ',' ) ); + return $set; + } + + /** + * Parse an UPDATE statement. + * + * @param array $with The CTE map from a preceding WITH clause. + * @return array The update node. + */ + private function parse_update( $with ) { + $this->consume_keyword( 'UPDATE' ); + if ( $this->try_consume_keyword( 'OR' ) ) { + $this->next(); + } + $db = null; + $name = $this->consume_identifier(); + if ( $this->try_consume_operator( '.' ) ) { + $db = strtolower( $name ); + $name = $this->consume_identifier(); + } + $alias = $this->parse_table_alias(); + $this->consume_keyword( 'SET' ); + $set = $this->parse_set_list(); + + $from = null; + if ( $this->try_consume_keyword( 'FROM' ) ) { + $from = $this->parse_from_clause(); + } + $where = null; + if ( $this->try_consume_keyword( 'WHERE' ) ) { + $where = $this->parse_expr(); + } + return array( + 't' => 'update', + 'with' => $with, + 'tbl' => $name, + 'db' => $db, + 'alias' => $alias, + 'set' => $set, + 'from' => $from, + 'where' => $where, + ); + } + + /** + * Parse a DELETE statement. + * + * @param array $with The CTE map from a preceding WITH clause. + * @return array The delete node. + */ + private function parse_delete( $with ) { + $this->consume_keyword( 'DELETE' ); + $this->consume_keyword( 'FROM' ); + $db = null; + $name = $this->consume_identifier(); + if ( $this->try_consume_operator( '.' ) ) { + $db = strtolower( $name ); + $name = $this->consume_identifier(); + } + $alias = $this->parse_table_alias(); + $where = null; + if ( $this->try_consume_keyword( 'WHERE' ) ) { + $where = $this->parse_expr(); + } + return array( + 't' => 'delete', + 'with' => $with, + 'tbl' => $name, + 'db' => $db, + 'alias' => $alias, + 'where' => $where, + ); + } + + /** + * Parse a CREATE statement (TABLE, INDEX, TRIGGER, or VIEW). + * + * @return array The create node. + */ + private function parse_create() { + $this->consume_keyword( 'CREATE' ); + $temporary = false; + if ( $this->try_consume_keyword( 'TEMPORARY' ) || $this->try_consume_keyword( 'TEMP' ) ) { + $temporary = true; + } + + if ( $this->try_consume_keyword( 'TABLE' ) ) { + return $this->parse_create_table( $temporary ); + } + + $unique = $this->try_consume_keyword( 'UNIQUE' ); + if ( $this->try_consume_keyword( 'INDEX' ) ) { + return $this->parse_create_index( $unique ); + } + + if ( $this->try_consume_keyword( 'TRIGGER' ) ) { + return $this->parse_create_trigger( $temporary ); + } + + if ( $this->try_consume_keyword( 'VIEW' ) ) { + $if_not_exists = $this->parse_if_not_exists(); + $name = $this->consume_qualified_name(); + $this->consume_keyword( 'AS' ); + $select = $this->parse_select(); + return array( + 't' => 'create_view', + 'name' => $name, + 'if_not_exists' => $if_not_exists, + 'sel' => $select, + 'sql' => trim( $this->sql ), + ); + } + + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $this->peek() ) ) + ); + } + + /** + * Parse an optional "IF NOT EXISTS" clause. + * + * @return bool Whether the clause was present. + */ + private function parse_if_not_exists() { + if ( $this->try_consume_keyword( 'IF' ) ) { + $this->consume_keyword( 'NOT' ); + $this->consume_keyword( 'EXISTS' ); + return true; + } + return false; + } + + /** + * Parse a possibly schema-qualified object name, ignoring the schema. + * + * @return string The object name. + */ + private function consume_qualified_name() { + $name = $this->consume_identifier(); + if ( $this->try_consume_operator( '.' ) ) { + $name = $this->consume_identifier(); + } + return $name; + } + + /** + * Parse a CREATE TABLE statement (after CREATE [TEMP] TABLE). + * + * @param bool $temporary Whether the table is temporary. + * @return array The create-table node. + */ + private function parse_create_table( $temporary ) { + $if_not_exists = $this->parse_if_not_exists(); + $name = $this->consume_qualified_name(); + + // CREATE TABLE ... AS SELECT. + if ( $this->try_consume_keyword( 'AS' ) ) { + return array( + 't' => 'create_table_as', + 'name' => $name, + 'temp' => $temporary, + 'if_not_exists' => $if_not_exists, + 'sel' => $this->parse_select(), + 'sql' => trim( $this->sql ), + ); + } + + $this->consume_operator( '(' ); + $columns = array(); + $constraints = array(); + do { + $token = $this->peek(); + if ( self::is_keyword( $token ) && in_array( $token[1], array( 'PRIMARY', 'UNIQUE', 'CHECK', 'FOREIGN', 'CONSTRAINT' ), true ) ) { + $constraints[] = $this->parse_table_constraint(); + } else { + $columns[] = $this->parse_column_definition(); + } + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + + $strict = false; + $without_rowid = false; + while ( true ) { + if ( $this->try_consume_keyword( 'STRICT' ) ) { + $strict = true; + } elseif ( $this->try_consume_keyword( 'WITHOUT' ) ) { + $this->consume_identifier(); // "ROWID". + $without_rowid = true; + } else { + break; + } + if ( ! $this->try_consume_operator( ',' ) ) { + break; + } + } + + return array( + 't' => 'create_table', + 'name' => $name, + 'temp' => $temporary, + 'if_not_exists' => $if_not_exists, + 'columns' => $columns, + 'constraints' => $constraints, + 'strict' => $strict, + 'without_rowid' => $without_rowid, + 'sql' => trim( $this->sql ), + ); + } + + /** + * Parse a column definition in CREATE TABLE. + * + * @return array The column definition. + */ + private function parse_column_definition() { + $name = $this->consume_identifier_or_string(); + $column = array( + 'name' => $name, + 'type' => null, + 'notnull' => false, + 'default' => null, + 'default_text' => null, + 'has_default' => false, + 'pk' => false, + 'pk_desc' => false, + 'autoincrement' => false, + 'unique' => false, + 'collate' => null, + 'checks' => array(), + 'fk' => null, + 'generated' => false, + ); + + // Optional data type: one or more words, optionally with (N) or (N, M). + $type_parts = array(); + while ( true ) { + $token = $this->peek(); + if ( self::is_identifier( $token ) ) { + // Make sure this is not an alias-like trailing word. + $type_parts[] = $token[1]; + $this->next(); + continue; + } + break; + } + if ( count( $type_parts ) > 0 && $this->try_consume_operator( '(' ) ) { + $numbers = array(); + do { + $num_token = $this->next(); + $numbers[] = $num_token[1]; + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + $type_parts[] = '(' . implode( ',', $numbers ) . ')'; + } + if ( count( $type_parts ) > 0 ) { + $column['type'] = implode( ' ', $type_parts ); + // SQLite normalizes single-word standard types to uppercase. + if ( 1 === count( $type_parts ) + && in_array( strtoupper( $column['type'] ), array( 'TEXT', 'INT', 'INTEGER', 'BLOB', 'REAL', 'DOUBLE', 'ANY' ), true ) ) { + $column['type'] = strtoupper( $column['type'] ); + } + } + + // Column constraints. + while ( true ) { + $constraint_name = null; + if ( $this->try_consume_keyword( 'CONSTRAINT' ) ) { + $constraint_name = $this->consume_identifier(); + } + if ( $this->try_consume_keyword( 'PRIMARY' ) ) { + $this->consume_keyword( 'KEY' ); + $column['pk'] = true; + if ( $this->try_consume_keyword( 'DESC' ) ) { + $column['pk_desc'] = true; + } else { + $this->try_consume_keyword( 'ASC' ); + } + $this->parse_conflict_clause(); + if ( $this->try_consume_keyword( 'AUTOINCREMENT' ) ) { + $column['autoincrement'] = true; + } + } elseif ( $this->try_consume_keyword( 'NOT' ) ) { + $this->consume_keyword( 'NULL' ); + $column['notnull'] = true; + $this->parse_conflict_clause(); + } elseif ( $this->try_consume_keyword( 'NULL' ) ) { + continue; + } elseif ( $this->try_consume_keyword( 'UNIQUE' ) ) { + $column['unique'] = true; + $this->parse_conflict_clause(); + } elseif ( $this->try_consume_keyword( 'CHECK' ) ) { + $this->consume_operator( '(' ); + $column['checks'][] = array( + 'name' => $constraint_name, + 'e' => $this->parse_expr(), + ); + $this->consume_operator( ')' ); + } elseif ( $this->try_consume_keyword( 'DEFAULT' ) ) { + $column['has_default'] = true; + if ( $this->try_consume_operator( '(' ) ) { + $default_start = $this->pos; + $column['default'] = $this->parse_expr(); + $default_end = $this->pos - 1; + $this->consume_operator( ')' ); + } else { + $default_start = $this->pos; + $column['default'] = $this->parse_default_literal(); + $default_end = $this->pos - 1; + } + // SQLite reports the default as written in the DDL. + $column['default_text'] = $this->source_text( $default_start, $default_end ); + } elseif ( $this->try_consume_keyword( 'COLLATE' ) ) { + $column['collate'] = strtoupper( $this->consume_identifier() ); + } elseif ( $this->try_consume_keyword( 'REFERENCES' ) ) { + $column['fk'] = $this->parse_foreign_key_target( array( $name ) ); + } elseif ( $this->peek_keyword( 'GENERATED' ) || $this->try_consume_identifier_word( 'GENERATED' ) ) { + // GENERATED ALWAYS AS ( expr ) [STORED|VIRTUAL]. + $this->try_consume_identifier_word( 'ALWAYS' ); + $this->consume_keyword( 'AS' ); + $this->consume_operator( '(' ); + $column['generated'] = $this->parse_expr(); + $this->consume_operator( ')' ); + $this->try_consume_identifier_word( 'STORED' ); + $this->try_consume_keyword( 'VIRTUAL' ); + } else { + if ( null !== $constraint_name ) { + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $this->peek() ) ) + ); + } + break; + } + } + + return $column; + } + + /** + * Parse a literal value for a DEFAULT clause. + * + * @return array The expression node. + */ + private function parse_default_literal() { + // DEFAULT accepts literals, signed numbers, and certain keywords. + return $this->parse_expr_unary(); + } + + /** + * Parse an ON CONFLICT clause in column/table constraints (ignored). + */ + private function parse_conflict_clause() { + if ( $this->try_consume_keyword( 'ON' ) ) { + $this->consume_keyword( 'CONFLICT' ); + $this->next(); // ROLLBACK | ABORT | FAIL | IGNORE | REPLACE. + } + } + + /** + * Parse a table-level constraint in CREATE TABLE. + * + * @return array The constraint node. + */ + private function parse_table_constraint() { + $name = null; + if ( $this->try_consume_keyword( 'CONSTRAINT' ) ) { + $name = $this->consume_identifier(); + } + if ( $this->try_consume_keyword( 'PRIMARY' ) ) { + $this->consume_keyword( 'KEY' ); + $cols = $this->parse_indexed_column_list(); + $this->parse_conflict_clause(); + return array( + 'kind' => 'pk', + 'name' => $name, + 'cols' => $cols, + ); + } + if ( $this->try_consume_keyword( 'UNIQUE' ) ) { + $cols = $this->parse_indexed_column_list(); + $this->parse_conflict_clause(); + return array( + 'kind' => 'unique', + 'name' => $name, + 'cols' => $cols, + ); + } + if ( $this->try_consume_keyword( 'CHECK' ) ) { + $this->consume_operator( '(' ); + $expr = $this->parse_expr(); + $this->consume_operator( ')' ); + return array( + 'kind' => 'check', + 'name' => $name, + 'e' => $expr, + ); + } + if ( $this->try_consume_keyword( 'FOREIGN' ) ) { + $this->consume_keyword( 'KEY' ); + $this->consume_operator( '(' ); + $cols = array(); + do { + $cols[] = $this->consume_identifier(); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + $this->consume_keyword( 'REFERENCES' ); + return array( + 'kind' => 'fk', + 'name' => $name, + 'fk' => $this->parse_foreign_key_target( $cols ), + ); + } + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $this->peek() ) ) + ); + } + + /** + * Parse the target of a REFERENCES clause. + * + * @param array $cols The referencing column names. + * @return array The foreign key definition. + */ + private function parse_foreign_key_target( $cols ) { + $table = $this->consume_identifier(); + $ref_cols = null; + if ( $this->try_consume_operator( '(' ) ) { + $ref_cols = array(); + do { + $ref_cols[] = $this->consume_identifier(); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + } + $on_delete = 'NO ACTION'; + $on_update = 'NO ACTION'; + while ( true ) { + if ( $this->try_consume_keyword( 'ON' ) ) { + $which = $this->next(); + $action = $this->parse_foreign_key_action(); + if ( 'DELETE' === $which[1] ) { + $on_delete = $action; + } else { + $on_update = $action; + } + } elseif ( $this->try_consume_keyword( 'MATCH' ) ) { + $this->next(); + } elseif ( $this->try_consume_keyword( 'NOT' ) || $this->peek_keyword( 'DEFERRABLE' ) ) { + $this->try_consume_keyword( 'DEFERRABLE' ); + if ( $this->try_consume_keyword( 'INITIALLY' ) ) { + $this->next(); + } + } else { + break; + } + } + return array( + 'cols' => $cols, + 'ref_table' => $table, + 'ref_cols' => $ref_cols, + 'on_delete' => $on_delete, + 'on_update' => $on_update, + ); + } + + /** + * Parse a foreign key action. + * + * @return string The action. + */ + private function parse_foreign_key_action() { + if ( $this->try_consume_keyword( 'CASCADE' ) ) { + return 'CASCADE'; + } + if ( $this->try_consume_keyword( 'RESTRICT' ) ) { + return 'RESTRICT'; + } + if ( $this->try_consume_keyword( 'NO' ) ) { + $this->consume_keyword( 'ACTION' ); + return 'NO ACTION'; + } + if ( $this->try_consume_keyword( 'SET' ) ) { + if ( $this->try_consume_keyword( 'NULL' ) ) { + return 'SET NULL'; + } + $this->consume_keyword( 'DEFAULT' ); + return 'SET DEFAULT'; + } + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $this->peek() ) ) + ); + } + + /** + * Parse a parenthesized indexed-column list, e.g. "(a, b COLLATE NOCASE DESC)". + * + * @return array A list of array( name, collate, dir ). + */ + private function parse_indexed_column_list() { + $this->consume_operator( '(' ); + $cols = array(); + do { + $col = $this->consume_identifier_or_string(); + $collate = null; + if ( $this->try_consume_keyword( 'COLLATE' ) ) { + $collate = strtoupper( $this->consume_identifier() ); + } + $dir = 'ASC'; + if ( $this->try_consume_keyword( 'DESC' ) ) { + $dir = 'DESC'; + } else { + $this->try_consume_keyword( 'ASC' ); + } + $cols[] = array( + 'name' => $col, + 'collate' => $collate, + 'dir' => $dir, + ); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + return $cols; + } + + /** + * Parse a CREATE INDEX statement (after CREATE [UNIQUE] INDEX). + * + * @param bool $unique Whether the index is unique. + * @return array The create-index node. + */ + private function parse_create_index( $unique ) { + $if_not_exists = $this->parse_if_not_exists(); + $name = $this->consume_qualified_name(); + $this->consume_keyword( 'ON' ); + $table = $this->consume_identifier(); + $cols = $this->parse_indexed_column_list(); + $where = null; + if ( $this->try_consume_keyword( 'WHERE' ) ) { + $where = $this->parse_expr(); + } + return array( + 't' => 'create_index', + 'name' => $name, + 'unique' => $unique, + 'if_not_exists' => $if_not_exists, + 'tbl' => $table, + 'cols' => $cols, + 'where' => $where, + 'sql' => trim( $this->sql ), + ); + } + + /** + * Parse a CREATE TRIGGER statement (after CREATE [TEMP] TRIGGER). + * + * @param bool $temporary Whether the trigger is temporary. + * @return array The create-trigger node. + */ + private function parse_create_trigger( $temporary ) { + $if_not_exists = $this->parse_if_not_exists(); + $name = $this->consume_qualified_name(); + + $timing = 'AFTER'; + if ( $this->try_consume_keyword( 'BEFORE' ) ) { + $timing = 'BEFORE'; + } elseif ( $this->try_consume_keyword( 'AFTER' ) ) { + $timing = 'AFTER'; + } elseif ( $this->try_consume_identifier_word( 'INSTEAD' ) ) { + $this->consume_keyword( 'OF' ); + $timing = 'INSTEAD OF'; + } + + $event = null; + $of_cols = null; + if ( $this->try_consume_keyword( 'INSERT' ) ) { + $event = 'INSERT'; + } elseif ( $this->try_consume_keyword( 'DELETE' ) ) { + $event = 'DELETE'; + } elseif ( $this->try_consume_keyword( 'UPDATE' ) ) { + $event = 'UPDATE'; + if ( $this->try_consume_keyword( 'OF' ) ) { + $of_cols = array(); + do { + $of_cols[] = $this->consume_identifier(); + } while ( $this->try_consume_operator( ',' ) ); + } + } else { + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $this->peek() ) ) + ); + } + + $this->consume_keyword( 'ON' ); + $table = $this->consume_qualified_name(); + + if ( $this->try_consume_keyword( 'FOR' ) ) { + $this->consume_keyword( 'EACH' ); + $this->consume_keyword( 'ROW' ); + } + $when = null; + if ( $this->try_consume_keyword( 'WHEN' ) ) { + $when = $this->parse_expr(); + } + + $this->consume_keyword( 'BEGIN' ); + $body = array(); + while ( ! $this->try_consume_keyword( 'END' ) ) { + $body[] = $this->parse_statement(); + $this->consume_operator( ';' ); + } + + return array( + 't' => 'create_trigger', + 'name' => $name, + 'temp' => $temporary, + 'if_not_exists' => $if_not_exists, + 'timing' => $timing, + 'event' => $event, + 'of_cols' => $of_cols, + 'tbl' => $table, + 'when' => $when, + 'body' => $body, + 'sql' => trim( $this->sql ), + ); + } + + /** + * Parse a DROP statement. + * + * @return array The drop node. + */ + private function parse_drop() { + $this->consume_keyword( 'DROP' ); + $token = $this->next(); + $what = strtolower( $token[1] ); + if ( ! in_array( $what, array( 'table', 'index', 'trigger', 'view' ), true ) ) { + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $token ) ) + ); + } + $if_exists = false; + if ( $this->try_consume_keyword( 'IF' ) ) { + $this->consume_keyword( 'EXISTS' ); + $if_exists = true; + } + return array( + 't' => 'drop', + 'what' => $what, + 'if_exists' => $if_exists, + 'name' => $this->consume_qualified_name(), + ); + } + + /** + * Parse an ALTER TABLE statement. + * + * @return array The alter node. + */ + private function parse_alter() { + $this->consume_keyword( 'ALTER' ); + $this->consume_keyword( 'TABLE' ); + $name = $this->consume_qualified_name(); + + if ( $this->try_consume_keyword( 'RENAME' ) ) { + if ( $this->try_consume_keyword( 'TO' ) ) { + return array( + 't' => 'alter_rename_table', + 'tbl' => $name, + 'new' => $this->consume_identifier(), + ); + } + $this->try_consume_keyword( 'COLUMN' ); + $old = $this->consume_identifier(); + $this->consume_keyword( 'TO' ); + return array( + 't' => 'alter_rename_column', + 'tbl' => $name, + 'old' => $old, + 'new' => $this->consume_identifier(), + ); + } + if ( $this->try_consume_keyword( 'ADD' ) ) { + $this->try_consume_keyword( 'COLUMN' ); + return array( + 't' => 'alter_add_column', + 'tbl' => $name, + 'col' => $this->parse_column_definition(), + ); + } + if ( $this->try_consume_keyword( 'DROP' ) ) { + $this->try_consume_keyword( 'COLUMN' ); + return array( + 't' => 'alter_drop_column', + 'tbl' => $name, + 'col' => $this->consume_identifier(), + ); + } + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $this->peek() ) ) + ); + } + + /** + * Parse a PRAGMA statement. + * + * @return array The pragma node. + */ + private function parse_pragma() { + $this->consume_keyword( 'PRAGMA' ); + $name = strtolower( $this->consume_qualified_name() ); + + $value = null; + $arg = null; + if ( $this->try_consume_operator( '=' ) ) { + $token = $this->next(); + if ( self::is_keyword( $token ) || self::is_identifier( $token ) ) { + $value = $token[1]; + } else { + $value = $token[1]; + } + } elseif ( $this->try_consume_operator( '(' ) ) { + $token = $this->next(); + $arg = $token[1]; + $this->consume_operator( ')' ); + } + return array( + 't' => 'pragma', + 'name' => $name, + 'value' => $value, + 'arg' => $arg, + ); + } + + /* + * ---------------------------------------------------------------------- + * Expression parsing (precedence climbing). + * ---------------------------------------------------------------------- + */ + + /** + * Parse an expression. + * + * @return array The expression node. + */ + public function parse_expr() { + return $this->parse_expr_or(); + } + + /** + * Parse an OR expression. + * + * @return array The expression node. + */ + private function parse_expr_or() { + $left = $this->parse_expr_and(); + while ( $this->try_consume_keyword( 'OR' ) ) { + $left = array( + 't' => 'bin', + 'op' => 'OR', + 'l' => $left, + 'r' => $this->parse_expr_and(), + ); + } + return $left; + } + + /** + * Parse an AND expression. + * + * @return array The expression node. + */ + private function parse_expr_and() { + $left = $this->parse_expr_not(); + while ( $this->try_consume_keyword( 'AND' ) ) { + $left = array( + 't' => 'bin', + 'op' => 'AND', + 'l' => $left, + 'r' => $this->parse_expr_not(), + ); + } + return $left; + } + + /** + * Parse a NOT expression. + * + * @return array The expression node. + */ + private function parse_expr_not() { + if ( $this->peek_keyword( 'NOT' ) && ! $this->peek_keyword_at( 1, 'EXISTS' ) ) { + $this->next(); + return array( + 't' => 'un', + 'op' => 'NOT', + 'e' => $this->parse_expr_not(), + ); + } + return $this->parse_expr_comparison(); + } + + /** + * Parse a comparison expression, including IN, LIKE, BETWEEN, and IS. + * + * @return array The expression node. + */ + private function parse_expr_comparison() { + $left = $this->parse_expr_bitwise(); + + while ( true ) { + $token = $this->peek(); + if ( self::is_operator( $token ) && in_array( $token[1], array( '=', '==', '!=', '<>', '<', '<=', '>', '>=' ), true ) ) { + $this->next(); + $op = '==' === $token[1] ? '=' : ( '<>' === $token[1] ? '!=' : $token[1] ); + $left = array( + 't' => 'bin', + 'op' => $op, + 'l' => $left, + 'r' => $this->parse_expr_bitwise(), + ); + continue; + } + + if ( self::is_keyword( $token ) ) { + $not = false; + $pos = $this->pos; + if ( 'NOT' === $token[1] ) { + $ahead = $this->peek_at( 1 ); + if ( self::is_keyword( $ahead ) && in_array( $ahead[1], array( 'IN', 'LIKE', 'GLOB', 'REGEXP', 'MATCH', 'BETWEEN', 'NULL' ), true ) ) { + $this->next(); + $not = true; + $token = $this->peek(); + } else { + break; + } + } + + if ( 'IN' === $token[1] ) { + $this->next(); + $left = $this->parse_in_rhs( $left, $not ); + continue; + } + if ( in_array( $token[1], array( 'LIKE', 'GLOB', 'REGEXP', 'MATCH' ), true ) ) { + $this->next(); + $pattern = $this->parse_expr_bitwise(); + $escape = null; + if ( $this->try_consume_keyword( 'ESCAPE' ) ) { + $escape = $this->parse_expr_bitwise(); + } + $left = array( + 't' => 'like', + 'op' => $token[1], + 'not' => $not, + 'e' => $left, + 'p' => $pattern, + 'escape' => $escape, + ); + continue; + } + if ( 'BETWEEN' === $token[1] ) { + $this->next(); + $lo = $this->parse_expr_bitwise(); + $this->consume_keyword( 'AND' ); + $hi = $this->parse_expr_bitwise(); + $left = array( + 't' => 'between', + 'not' => $not, + 'e' => $left, + 'lo' => $lo, + 'hi' => $hi, + ); + continue; + } + if ( 'ISNULL' === $token[1] ) { + $this->next(); + $left = array( + 't' => 'isnull', + 'not' => false, + 'e' => $left, + ); + continue; + } + if ( 'NOTNULL' === $token[1] ) { + $this->next(); + $left = array( + 't' => 'isnull', + 'not' => true, + 'e' => $left, + ); + continue; + } + if ( 'IS' === $token[1] ) { + $this->next(); + $is_not = $this->try_consume_keyword( 'NOT' ); + if ( $this->try_consume_identifier_word( 'DISTINCT' ) || $this->try_consume_keyword( 'DISTINCT' ) ) { + $this->consume_keyword( 'FROM' ); + $is_not = ! $is_not; + } + $left = array( + 't' => 'is', + 'not' => $is_not, + 'l' => $left, + 'r' => $this->parse_expr_bitwise(), + ); + continue; + } + if ( $not ) { + // "NOT NULL" used as an operator suffix is invalid here. + $this->pos = $pos; + break; + } + } + break; + } + return $left; + } + + /** + * Parse the right-hand side of an IN operator. + * + * @param array $left The left-hand expression. + * @param bool $not Whether the operator is NOT IN. + * @return array The expression node. + */ + private function parse_in_rhs( $left, $not ) { + if ( $this->try_consume_operator( '(' ) ) { + if ( $this->peek_keyword( 'SELECT' ) || $this->peek_keyword( 'WITH' ) || $this->peek_keyword( 'VALUES' ) ) { + $select = $this->parse_select(); + $this->consume_operator( ')' ); + return array( + 't' => 'in', + 'not' => $not, + 'e' => $left, + 'sub' => $select, + ); + } + $list = array(); + if ( ! $this->peek_operator( ')' ) ) { + do { + $list[] = $this->parse_expr(); + } while ( $this->try_consume_operator( ',' ) ); + } + $this->consume_operator( ')' ); + return array( + 't' => 'in', + 'not' => $not, + 'e' => $left, + 'list' => $list, + ); + } + + return array( + 't' => 'in', + 'not' => $not, + 'e' => $left, + 'sub' => array( + 't' => 'select', + 'with' => array(), + 'parts' => array( + array( + 't' => 'core', + 'distinct' => false, + 'items' => array( + array( + 'star' => true, + 'tbl' => null, + ), + ), + 'from' => $this->parse_table_or_subquery(), + 'where' => null, + 'group' => null, + 'having' => null, + ), + ), + 'ops' => array(), + 'order' => null, + 'limit' => null, + 'offset' => null, + ), + ); + } + + /** + * Parse bitwise/shift operators (also the || concatenation operator). + * + * @return array The expression node. + */ + private function parse_expr_bitwise() { + $left = $this->parse_expr_additive(); + while ( true ) { + $token = $this->peek(); + if ( self::is_operator( $token ) && in_array( $token[1], array( '<<', '>>', '&', '|' ), true ) ) { + $this->next(); + $left = array( + 't' => 'bin', + 'op' => $token[1], + 'l' => $left, + 'r' => $this->parse_expr_additive(), + ); + continue; + } + break; + } + return $left; + } + + /** + * Parse additive operators. + * + * @return array The expression node. + */ + private function parse_expr_additive() { + $left = $this->parse_expr_multiplicative(); + while ( true ) { + $token = $this->peek(); + if ( self::is_operator( $token ) && ( '+' === $token[1] || '-' === $token[1] ) ) { + $this->next(); + $left = array( + 't' => 'bin', + 'op' => $token[1], + 'l' => $left, + 'r' => $this->parse_expr_multiplicative(), + ); + continue; + } + break; + } + return $left; + } + + /** + * Parse multiplicative operators. + * + * @return array The expression node. + */ + private function parse_expr_multiplicative() { + $left = $this->parse_expr_concat(); + while ( true ) { + $token = $this->peek(); + if ( self::is_operator( $token ) && ( '*' === $token[1] || '/' === $token[1] || '%' === $token[1] ) ) { + $this->next(); + $left = array( + 't' => 'bin', + 'op' => $token[1], + 'l' => $left, + 'r' => $this->parse_expr_concat(), + ); + continue; + } + break; + } + return $left; + } + + /** + * Parse the || concatenation operator. + * + * @return array The expression node. + */ + private function parse_expr_concat() { + $left = $this->parse_expr_collate(); + while ( true ) { + $token = $this->peek(); + if ( self::is_operator( $token ) && '||' === $token[1] ) { + $this->next(); + $left = array( + 't' => 'bin', + 'op' => '||', + 'l' => $left, + 'r' => $this->parse_expr_collate(), + ); + continue; + } + break; + } + return $left; + } + + /** + * Parse a COLLATE postfix operator. + * + * @return array The expression node. + */ + private function parse_expr_collate() { + $expr = $this->parse_expr_unary(); + while ( $this->try_consume_keyword( 'COLLATE' ) ) { + $expr = array( + 't' => 'collate', + 'name' => strtoupper( $this->consume_identifier() ), + 'e' => $expr, + ); + } + return $expr; + } + + /** + * Parse unary operators. + * + * @return array The expression node. + */ + private function parse_expr_unary() { + $token = $this->peek(); + if ( self::is_operator( $token ) && ( '-' === $token[1] || '+' === $token[1] || '~' === $token[1] ) ) { + $this->next(); + return array( + 't' => 'un', + 'op' => $token[1], + 'e' => $this->parse_expr_unary(), + ); + } + return $this->parse_expr_primary(); + } + + /** + * Parse a primary expression. + * + * @return array The expression node. + */ + private function parse_expr_primary() { + $token = $this->peek(); + if ( null === $token ) { + throw new WP_PHP_Engine_SQL_Exception( 'incomplete input' ); + } + + // Literals. + if ( WP_PHP_Engine_Lexer::TYPE_NUMBER === $token[0] + || WP_PHP_Engine_Lexer::TYPE_STRING === $token[0] + || WP_PHP_Engine_Lexer::TYPE_BLOB === $token[0] ) { + $this->next(); + return array( + 't' => 'lit', + 'v' => WP_PHP_Engine_Lexer::TYPE_BLOB === $token[0] ? new WP_PHP_Engine_Blob( $token[1] ) : $token[1], + ); + } + + // Parameters. + if ( WP_PHP_Engine_Lexer::TYPE_PARAMETER === $token[0] ) { + $this->next(); + if ( null === $token[1] ) { + $index = $this->parameter_count; + $this->parameter_count += 1; + } elseif ( is_string( $token[1] ) && ctype_digit( $token[1] ) ) { + $index = (int) $token[1] - 1; + $this->parameter_count = max( $this->parameter_count, $index + 1 ); + } else { + $index = $token[1]; // Named parameter. + } + return array( + 't' => 'param', + 'i' => $index, + ); + } + + // Parenthesized expression or subquery. + if ( self::is_operator( $token ) && '(' === $token[1] ) { + $this->next(); + if ( $this->peek_keyword( 'SELECT' ) || $this->peek_keyword( 'WITH' ) || $this->peek_keyword( 'VALUES' ) ) { + $select = $this->parse_select(); + $this->consume_operator( ')' ); + return array( + 't' => 'sub', + 'sel' => $select, + ); + } + $exprs = array(); + do { + $exprs[] = $this->parse_expr(); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + if ( 1 === count( $exprs ) ) { + return $exprs[0]; + } + return array( + 't' => 'row', + 'exprs' => $exprs, + ); + } + + // Keyword-led expressions. + if ( self::is_keyword( $token ) ) { + switch ( $token[1] ) { + case 'NULL': + $this->next(); + return array( + 't' => 'lit', + 'v' => null, + ); + case 'TRUE': + $this->next(); + return array( + 't' => 'lit', + 'v' => 1, + ); + case 'FALSE': + $this->next(); + return array( + 't' => 'lit', + 'v' => 0, + ); + case 'CURRENT_TIMESTAMP': + case 'CURRENT_DATE': + case 'CURRENT_TIME': + $this->next(); + return array( + 't' => 'now', + 'fn' => $token[1], + ); + case 'CASE': + return $this->parse_case(); + case 'CAST': + $this->next(); + $this->consume_operator( '(' ); + $expr = $this->parse_expr(); + $this->consume_keyword( 'AS' ); + $type_parts = array(); + while ( true ) { + $next = $this->peek(); + if ( self::is_identifier( $next ) ) { + $type_parts[] = $next[1]; + $this->next(); + continue; + } + break; + } + if ( $this->try_consume_operator( '(' ) ) { + do { + $this->next(); + } while ( $this->try_consume_operator( ',' ) ); + $this->consume_operator( ')' ); + } + $this->consume_operator( ')' ); + return array( + 't' => 'cast', + 'e' => $expr, + 'as' => implode( ' ', $type_parts ), + ); + case 'NOT': + // NOT EXISTS (...). + $this->next(); + $this->consume_keyword( 'EXISTS' ); + $this->consume_operator( '(' ); + $select = $this->parse_select(); + $this->consume_operator( ')' ); + return array( + 't' => 'exists', + 'not' => true, + 'sel' => $select, + ); + case 'EXISTS': + $this->next(); + $this->consume_operator( '(' ); + $select = $this->parse_select(); + $this->consume_operator( ')' ); + return array( + 't' => 'exists', + 'not' => false, + 'sel' => $select, + ); + case 'RAISE': + $this->next(); + $this->consume_operator( '(' ); + $kind = $this->next(); + $msg = null; + if ( $this->try_consume_operator( ',' ) ) { + $msg = $this->parse_expr(); + } + $this->consume_operator( ')' ); + return array( + 't' => 'raise', + 'kind' => $kind[1], + 'msg' => $msg, + ); + case 'REPLACE': + case 'IF': + case 'GLOB': + case 'LIKE': + case 'LEFT': + case 'RIGHT': + // Keywords that can also be function names. + if ( $this->peek_operator_at( 1, '(' ) ) { + $this->next(); + return $this->parse_function_call( $token[1] ); + } + break; + } + } + + // Identifiers: column references and function calls. + if ( self::is_identifier( $token ) ) { + $this->next(); + if ( $this->peek_operator( '(' ) ) { + return $this->parse_function_call( $token[1] ); + } + $parts = array( $token[1] ); + while ( $this->peek_operator( '.' ) ) { + $this->next(); + $next = $this->next(); + if ( null === $next || ( ! self::is_identifier( $next ) && ! self::is_keyword( $next ) && ! self::is_string( $next ) ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'expected a column name after "."' ); + } + $parts[] = $next[1]; + } + if ( 1 === count( $parts ) ) { + return array( + 't' => 'col', + 'db' => null, + 'tbl' => null, + 'name' => $parts[0], + ); + } + if ( 2 === count( $parts ) ) { + return array( + 't' => 'col', + 'db' => null, + 'tbl' => $parts[0], + 'name' => $parts[1], + ); + } + return array( + 't' => 'col', + 'db' => $parts[0], + 'tbl' => $parts[1], + 'name' => $parts[2], + ); + } + + // "*" as a bare expression (e.g. COUNT(*) handled in functions, but + // also "SELECT 1 WHERE 1" style edge cases fall through to errors). + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $token ) ) + ); + } + + /** + * Parse a CASE expression. + * + * @return array The expression node. + */ + private function parse_case() { + $this->consume_keyword( 'CASE' ); + $operand = null; + if ( ! $this->peek_keyword( 'WHEN' ) ) { + $operand = $this->parse_expr(); + } + $when = array(); + while ( $this->try_consume_keyword( 'WHEN' ) ) { + $cond = $this->parse_expr(); + $this->consume_keyword( 'THEN' ); + $when[] = array( $cond, $this->parse_expr() ); + } + $else = null; + if ( $this->try_consume_keyword( 'ELSE' ) ) { + $else = $this->parse_expr(); + } + $this->consume_keyword( 'END' ); + return array( + 't' => 'case', + 'operand' => $operand, + 'when' => $when, + 'else' => $else, + ); + } + + /** + * Parse a function call after its name (positioned at the opening paren). + * + * @param string $name The function name. + * @return array The expression node. + */ + private function parse_function_call( $name ) { + $this->consume_operator( '(' ); + $distinct = false; + $star = false; + $args = array(); + if ( $this->try_consume_operator( '*' ) ) { + $star = true; + } elseif ( ! $this->peek_operator( ')' ) ) { + if ( $this->try_consume_keyword( 'DISTINCT' ) ) { + $distinct = true; + } + do { + $args[] = $this->parse_expr(); + } while ( $this->try_consume_operator( ',' ) ); + } + $this->consume_operator( ')' ); + + // FILTER (WHERE ...) — parsed and ignored (rarely used). + if ( $this->try_consume_keyword( 'FILTER' ) ) { + $this->consume_operator( '(' ); + $this->consume_keyword( 'WHERE' ); + $this->parse_expr(); + $this->consume_operator( ')' ); + } + + $over = null; + if ( $this->try_consume_keyword( 'OVER' ) ) { + $over = array( + 'partition' => array(), + 'order' => array(), + ); + $this->consume_operator( '(' ); + if ( $this->try_consume_keyword( 'PARTITION' ) ) { + $this->consume_keyword( 'BY' ); + do { + $over['partition'][] = $this->parse_expr(); + } while ( $this->try_consume_operator( ',' ) ); + } + if ( $this->try_consume_keyword( 'ORDER' ) ) { + $this->consume_keyword( 'BY' ); + $over['order'] = $this->parse_order_by_list(); + } + $this->consume_operator( ')' ); + } + + return array( + 't' => 'fn', + 'name' => strtolower( $name ), + 'args' => $args, + 'distinct' => $distinct, + 'star' => $star, + 'over' => $over, + ); + } + + /* + * ---------------------------------------------------------------------- + * Token stream helpers. + * ---------------------------------------------------------------------- + */ + + /** + * Peek at the current token. + * + * @return array|null The token, or null at the end of input. + */ + private function peek() { + return isset( $this->tokens[ $this->pos ] ) ? $this->tokens[ $this->pos ] : null; + } + + /** + * Peek at a token at the given offset from the current position. + * + * @param int $offset The offset. + * @return array|null The token, or null. + */ + private function peek_at( $offset ) { + return isset( $this->tokens[ $this->pos + $offset ] ) ? $this->tokens[ $this->pos + $offset ] : null; + } + + /** + * Consume and return the current token. + * + * @return array|null The token, or null at the end of input. + */ + private function next() { + $token = $this->peek(); + if ( null !== $token ) { + $this->pos += 1; + } + return $token; + } + + /** + * Check whether a token is a keyword token. + * + * @param array|null $token The token. + * @return bool Whether the token is a keyword. + */ + private static function is_keyword( $token ) { + return null !== $token && WP_PHP_Engine_Lexer::TYPE_KEYWORD === $token[0]; + } + + /** + * Check whether a token is an identifier token. + * + * @param array|null $token The token. + * @return bool Whether the token is an identifier. + */ + private static function is_identifier( $token ) { + return null !== $token && WP_PHP_Engine_Lexer::TYPE_IDENTIFIER === $token[0]; + } + + /** + * Check whether a token is a string token. + * + * @param array|null $token The token. + * @return bool Whether the token is a string. + */ + private static function is_string( $token ) { + return null !== $token && WP_PHP_Engine_Lexer::TYPE_STRING === $token[0]; + } + + /** + * Check whether a token is an operator token. + * + * @param array|null $token The token. + * @return bool Whether the token is an operator. + */ + private static function is_operator( $token ) { + return null !== $token && WP_PHP_Engine_Lexer::TYPE_OPERATOR === $token[0]; + } + + /** + * Check whether the current token is the given keyword. + * + * @param string $keyword The keyword. + * @return bool Whether the current token matches. + */ + private function peek_keyword( $keyword ) { + $token = $this->peek(); + return self::is_keyword( $token ) && $token[1] === $keyword; + } + + /** + * Check whether the token at the given offset is the given keyword. + * + * @param int $offset The offset. + * @param string $keyword The keyword. + * @return bool Whether the token matches. + */ + private function peek_keyword_at( $offset, $keyword ) { + $token = $this->peek_at( $offset ); + return self::is_keyword( $token ) && $token[1] === $keyword; + } + + /** + * Check whether the current token is the given operator. + * + * @param string $op The operator. + * @return bool Whether the current token matches. + */ + private function peek_operator( $op ) { + $token = $this->peek(); + return self::is_operator( $token ) && $token[1] === $op; + } + + /** + * Check whether the token at the given offset is the given operator. + * + * @param int $offset The offset. + * @param string $op The operator. + * @return bool Whether the token matches. + */ + private function peek_operator_at( $offset, $op ) { + $token = $this->peek_at( $offset ); + return self::is_operator( $token ) && $token[1] === $op; + } + + /** + * Consume the given keyword or fail. + * + * @param string $keyword The keyword. + * @throws WP_PHP_Engine_SQL_Exception When the current token does not match. + */ + private function consume_keyword( $keyword ) { + if ( ! $this->try_consume_keyword( $keyword ) ) { + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $this->peek() ) ) + ); + } + } + + /** + * Consume the given keyword if it is the current token. + * + * @param string $keyword The keyword. + * @return bool Whether the keyword was consumed. + */ + private function try_consume_keyword( $keyword ) { + if ( $this->peek_keyword( $keyword ) ) { + $this->pos += 1; + return true; + } + return false; + } + + /** + * Consume one of the given keywords if the current token matches. + * + * @param array $keywords The keywords. + * @return string|null The consumed keyword, or null. + */ + private function try_consume_keywords( $keywords ) { + $token = $this->peek(); + if ( self::is_keyword( $token ) && in_array( $token[1], $keywords, true ) ) { + $this->pos += 1; + return $token[1]; + } + return null; + } + + /** + * Consume a specific non-keyword word (case-insensitively). + * + * @param string $word The word. + * @return bool Whether the word was consumed. + */ + private function try_consume_identifier_word( $word ) { + $token = $this->peek(); + if ( self::is_identifier( $token ) && 0 === strcasecmp( $token[1], $word ) ) { + $this->pos += 1; + return true; + } + return false; + } + + /** + * Consume the given operator or fail. + * + * @param string $op The operator. + * @throws WP_PHP_Engine_SQL_Exception When the current token does not match. + */ + private function consume_operator( $op ) { + if ( ! $this->try_consume_operator( $op ) ) { + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $this->peek() ) ) + ); + } + } + + /** + * Consume the given operator if it is the current token. + * + * @param string $op The operator. + * @return bool Whether the operator was consumed. + */ + private function try_consume_operator( $op ) { + if ( $this->peek_operator( $op ) ) { + $this->pos += 1; + return true; + } + return false; + } + + /** + * Consume an identifier (allowing non-reserved keywords) or fail. + * + * @return string The identifier. + * @throws WP_PHP_Engine_SQL_Exception When the current token is not an identifier. + */ + private function consume_identifier() { + $token = $this->next(); + if ( null !== $token + && ( WP_PHP_Engine_Lexer::TYPE_IDENTIFIER === $token[0] || WP_PHP_Engine_Lexer::TYPE_KEYWORD === $token[0] ) ) { + return $token[1]; + } + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $token ) ) + ); + } + + /** + * Consume an identifier or a string literal. + * + * @return string The value. + * @throws WP_PHP_Engine_SQL_Exception When the current token does not match. + */ + private function consume_identifier_or_string() { + $token = $this->next(); + if ( null !== $token + && ( WP_PHP_Engine_Lexer::TYPE_IDENTIFIER === $token[0] + || WP_PHP_Engine_Lexer::TYPE_KEYWORD === $token[0] + || WP_PHP_Engine_Lexer::TYPE_STRING === $token[0] ) ) { + return $token[1]; + } + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'near "%s": syntax error', $this->token_text( $token ) ) + ); + } + + /** + * Get the original SQL text spanning a token range. + * + * @param int $from The first token position. + * @param int $to The last token position. + * @return string The source text. + */ + private function source_text( $from, $to ) { + if ( ! isset( $this->tokens[ $from ], $this->tokens[ $to ] ) || $to < $from ) { + return ''; + } + $start = $this->tokens[ $from ][2]; + $end = $this->tokens[ $to ][3]; + return substr( $this->sql, $start, $end - $start ); + } + + /** + * Render a token for an error message. + * + * @param array|null $token The token. + * @return string The token text. + */ + private function token_text( $token ) { + if ( null === $token ) { + return ''; + } + return is_scalar( $token[1] ) ? (string) $token[1] : '?'; + } +} + +/** + * An exception thrown by the pure-PHP database engine. + * + * The message format mirrors SQLite/PDO error messages where the SQLite + * driver depends on them. + */ +class WP_PHP_Engine_SQL_Exception extends PDOException { + /** + * Constructor. + * + * The message is rendered the way PDO renders SQLite driver errors: + * + * SQLSTATE[HY000]: General error: 1 no such table: t + * SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: t.c + * + * @param string $message The error message. + * @param string $sqlstate The SQLSTATE error code. + * @param int $sqlite_code The SQLite-compatible error code. + * @param string $category The PDO error category text. + */ + public function __construct( $message, $sqlstate = 'HY000', $sqlite_code = 1, $category = 'General error' ) { + parent::__construct( + sprintf( 'SQLSTATE[%s]: %s: %d %s', $sqlstate, $category, $sqlite_code, $message ) + ); + // PDOException::$code is a string SQLSTATE value for driver errors. + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $this->code = $sqlstate; + $this->errorInfo = array( $sqlstate, $sqlite_code, $message ); + // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } +} diff --git a/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-pdo-statement.php b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-pdo-statement.php new file mode 100644 index 00000000..510aa5d5 --- /dev/null +++ b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-pdo-statement.php @@ -0,0 +1,612 @@ +pdo = $pdo; + $this->engine = $engine; + $this->sql = $query; + } + + /** + * Resolve the effective fetch mode. + * + * @param int|null $mode The requested mode (null or 0 for the default). + * @return int The effective mode. + */ + private function resolve_fetch_mode( $mode ) { + if ( null !== $mode && 0 !== $mode ) { + return $mode; + } + if ( null !== $this->fetch_mode ) { + return $this->fetch_mode; + } + $default = $this->pdo->getAttribute( PDO::ATTR_DEFAULT_FETCH_MODE ); + if ( null !== $default && 0 !== $default ) { + return $default; + } + return PDO::FETCH_BOTH; + } + + /** + * Bind a value to a parameter. + * + * @param mixed $param The parameter identifier (1-based int or name). + * @param mixed $value The value. + * @param int $type The parameter type. + * @return bool + */ + #[\ReturnTypeWillChange] + public function bindValue( $param, $value, $type = PDO::PARAM_STR ) { + $this->bound_params[ $this->normalize_param_key( $param ) ] = $this->convert_bound_value( $value, $type ); + return true; + } + + /** + * Bind a variable to a parameter by reference. + * + * Like in PDO, the variable is evaluated at execute() time, so changes + * made to it after binding are picked up. + * + * @param mixed $param The parameter identifier. + * @param mixed $var The variable. + * @param int $type The parameter type. + * @param int $max_length Ignored. + * @param mixed $driver_options Ignored. + * @return bool + */ + #[\ReturnTypeWillChange] + public function bindParam( $param, &$var, $type = PDO::PARAM_STR, $max_length = 0, $driver_options = null ) { + $key = $this->normalize_param_key( $param ); + $binding = array( + 'is_bound_reference' => true, + 'type' => $type, + ); + $binding['var'] = &$var; + $this->bound_params[ $key ] = $binding; + return true; + } + + /** + * Normalize a parameter identifier to the engine's array key. + * + * @param mixed $param The parameter identifier (1-based int or name). + * @return int|string The array key. + */ + private function normalize_param_key( $param ) { + return is_int( $param ) ? $param - 1 : ltrim( (string) $param, ':' ); + } + + /** + * Apply a PDO::PARAM_* type to a bound value. + * + * @param mixed $value The value. + * @param int $type The parameter type. + * @return mixed The converted value. + */ + private function convert_bound_value( $value, $type ) { + if ( PDO::PARAM_BOOL === $type ) { + return $value ? 1 : 0; + } + if ( PDO::PARAM_NULL === $type ) { + return null; + } + if ( PDO::PARAM_INT === $type && null !== $value ) { + return (int) $value; + } + return $value; + } + + /** + * Execute the statement. + * + * @param array|null $params Bound parameter values. + * @return bool + */ + #[\ReturnTypeWillChange] + public function execute( $params = null ) { + if ( null !== $params ) { + $bound = $this->normalize_execute_params( $params ); + } else { + // Resolve by-reference bindings to their current values. + $bound = array(); + foreach ( $this->bound_params as $key => $value ) { + if ( is_array( $value ) && isset( $value['is_bound_reference'] ) ) { + $bound[ $key ] = $this->convert_bound_value( $value['var'], $value['type'] ); + } else { + $bound[ $key ] = $value; + } + } + } + try { + $result = $this->engine->execute( $this->sql, $bound ); + } catch ( PDOException $e ) { + return false !== $this->pdo->handle_error( $e ) ? true : false; + } + $this->cols = $result['cols']; + $this->decl = $result['decl']; + $this->tables = isset( $result['srctable'] ) ? $result['srctable'] : array(); + $this->rows = $result['rows']; + $this->affected_rows = isset( $result['changes'] ) ? $result['changes'] : 0; + $this->cursor = 0; + return true; + } + + /** + * Normalize execute() parameters to the engine's expectations. + * + * @param array $params The parameters. + * @return array The normalized parameters. + */ + private function normalize_execute_params( $params ) { + $normalized = array(); + $position = 0; + foreach ( $params as $key => $value ) { + if ( is_int( $key ) ) { + // PDO accepts both 0-based lists and 1-based maps. + $normalized[ $position ] = $value; + $position += 1; + } else { + $normalized[ ltrim( (string) $key, ':' ) ] = $value; + } + } + return $normalized; + } + + /** + * Get the number of rows affected by the statement. + * + * @return int + */ + #[\ReturnTypeWillChange] + public function rowCount() { + return $this->affected_rows; + } + + /** + * Get the number of columns in the result set. + * + * @return int + */ + #[\ReturnTypeWillChange] + public function columnCount() { + return count( $this->cols ); + } + + /** + * Get metadata for a result column. + * + * @param int $column The 0-based column index. + * @return array|false + */ + #[\ReturnTypeWillChange] + public function getColumnMeta( $column ) { + if ( ! isset( $this->cols[ $column ] ) ) { + return false; + } + // Infer the native type from the first non-null value in the column. + $native_type = 'null'; + foreach ( $this->rows as $row ) { + $value = isset( $row[ $column ] ) ? $row[ $column ] : null; + if ( null !== $value ) { + if ( is_int( $value ) ) { + $native_type = 'integer'; + } elseif ( is_float( $value ) ) { + $native_type = 'double'; + } else { + // PDO SQLite reports both TEXT and BLOB as "string". + $native_type = 'string'; + } + break; + } + } + $meta = array( + 'native_type' => $native_type, + 'flags' => array(), + 'name' => $this->cols[ $column ], + 'len' => -1, + 'precision' => 0, + 'pdo_type' => PDO::PARAM_STR, + ); + if ( isset( $this->tables[ $column ] ) && null !== $this->tables[ $column ] ) { + $meta['table'] = $this->tables[ $column ]; + } + if ( isset( $this->decl[ $column ] ) && null !== $this->decl[ $column ] ) { + $meta['sqlite:decl_type'] = $this->decl[ $column ]; + } + return $meta; + } + + /** + * Set the default fetch mode. + * + * @param int $mode The fetch mode. + * @param mixed ...$args Additional arguments. + * @return bool + */ + #[\ReturnTypeWillChange] + public function setFetchMode( $mode, ...$args ) { + $this->fetch_mode = $mode; + $this->fetch_mode_args = $args; + return true; + } + + /** + * Fetch the next row. + * + * @param int|null $mode The fetch mode. + * @param int $cursor_orientation Ignored. + * @param int $cursor_offset Ignored. + * @return mixed + */ + #[\ReturnTypeWillChange] + public function fetch( $mode = null, $cursor_orientation = PDO::FETCH_ORI_NEXT, $cursor_offset = 0 ) { + if ( ! isset( $this->rows[ $this->cursor ] ) ) { + return false; + } + $row = $this->rows[ $this->cursor ]; + $this->cursor += 1; + return $this->format_row( $row, $this->resolve_fetch_mode( $mode ) ); + } + + /** + * Fetch a single column from the next row. + * + * @param int $column The 0-based column index. + * @return mixed + */ + #[\ReturnTypeWillChange] + public function fetchColumn( $column = 0 ) { + $this->validate_column_index( $column ); + if ( ! isset( $this->rows[ $this->cursor ] ) ) { + return false; + } + $row = $this->rows[ $this->cursor ]; + $this->cursor += 1; + $value = isset( $row[ $column ] ) || array_key_exists( $column, $row ) ? $row[ $column ] : null; + return $this->stringify( $value ); + } + + /** + * Validate a 0-based result-column index. + * + * @param int $column The column index. + */ + private function validate_column_index( $column ) { + if ( $column < 0 ) { + $this->throw_invalid_column_index( + PHP_VERSION_ID < 80000 ? 'Invalid column index' : 'Column index must be greater than or equal to 0' + ); + } + if ( $column >= count( $this->cols ) ) { + $this->throw_invalid_column_index( 'Invalid column index' ); + } + } + + /** + * Throw the PDO-compatible exception for invalid result-column access. + * + * @param string $message The exception message. + */ + private function throw_invalid_column_index( $message ) { + if ( PHP_VERSION_ID < 80000 ) { + throw new PDOException( $message ); + } + throw new ValueError( $message ); + } + + /** + * Fetch all remaining rows. + * + * @param int|null $mode The fetch mode. + * @param mixed ...$args Additional arguments. + * @return array + */ + #[\ReturnTypeWillChange] + public function fetchAll( $mode = null, ...$args ) { + $use_mode = $this->resolve_fetch_mode( $mode ); + + if ( PDO::FETCH_COLUMN === $use_mode ) { + $column = isset( $args[0] ) ? $args[0] : 0; + $result = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $row = $this->rows[ $this->cursor ]; + $this->cursor += 1; + $result[] = $this->stringify( isset( $row[ $column ] ) || array_key_exists( $column, $row ) ? $row[ $column ] : null ); + } + return $result; + } + + if ( PDO::FETCH_KEY_PAIR === $use_mode ) { + $result = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $row = $this->rows[ $this->cursor ]; + $this->cursor += 1; + $result[ $this->stringify( $row[0] ) ] = $this->stringify( isset( $row[1] ) ? $row[1] : null ); + } + return $result; + } + + $result = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $row = $this->rows[ $this->cursor ]; + $this->cursor += 1; + $result[] = $this->format_row( $row, $use_mode, $args ); + } + return $result; + } + + /** + * Fetch the next row as an object. + * + * @param string|null $class The class name. + * @param array $constructor_args The constructor arguments. + * @return object|false + */ + #[\ReturnTypeWillChange] + public function fetchObject( $class = 'stdClass', $constructor_args = array() ) { + $row = $this->fetch( PDO::FETCH_ASSOC ); + if ( false === $row ) { + return false; + } + if ( 'stdClass' === $class || null === $class ) { + return (object) $row; + } + $object = empty( $constructor_args ) + ? new $class() + : ( new ReflectionClass( $class ) )->newInstanceArgs( $constructor_args ); + foreach ( $row as $key => $value ) { + $object->$key = $value; + } + return $object; + } + + /** + * Format a row according to a fetch mode. + * + * @param array $row The raw row (list of values). + * @param int $mode The fetch mode. + * @param array $args Additional fetch mode arguments. + * @return mixed The formatted row. + */ + private function format_row( $row, $mode, $args = array() ) { + // Strip flags we do not implement. + $mode = $mode & ~PDO::FETCH_SERIALIZE & ~PDO::FETCH_PROPS_LATE; + + switch ( $mode ) { + case PDO::FETCH_NUM: + $result = array(); + foreach ( $row as $value ) { + $result[] = $this->stringify( $value ); + } + return $result; + + case PDO::FETCH_ASSOC: + return $this->assoc_row( $row ); + + case PDO::FETCH_NAMED: + // Duplicate column names are grouped into arrays. + $result = array(); + foreach ( $row as $index => $value ) { + $name = isset( $this->cols[ $index ] ) ? $this->cols[ $index ] : (string) $index; + $value = $this->stringify( $value ); + if ( ! isset( $result[ $name ] ) && ! array_key_exists( $name, $result ) ) { + $result[ $name ] = $value; + } elseif ( is_array( $result[ $name ] ) ) { + $result[ $name ][] = $value; + } else { + $result[ $name ] = array( $result[ $name ], $value ); + } + } + return $result; + + case PDO::FETCH_OBJ: + return (object) $this->assoc_row( $row ); + + case PDO::FETCH_CLASS: + $class = ! empty( $args ) ? $args[0] : ( ! empty( $this->fetch_mode_args ) ? $this->fetch_mode_args[0] : 'stdClass' ); + $assoc = $this->assoc_row( $row ); + if ( 'stdClass' === $class ) { + return (object) $assoc; + } + $object = new $class(); + foreach ( $assoc as $key => $value ) { + $object->$key = $value; + } + return $object; + + case PDO::FETCH_KEY_PAIR: + return array( $this->stringify( $row[0] ) => $this->stringify( isset( $row[1] ) ? $row[1] : null ) ); + + case PDO::FETCH_COLUMN: + $column = ! empty( $args ) ? $args[0] : 0; + return $this->stringify( isset( $row[ $column ] ) ? $row[ $column ] : null ); + + case PDO::FETCH_BOTH: + default: + $result = array(); + foreach ( $row as $index => $value ) { + $value = $this->stringify( $value ); + if ( isset( $this->cols[ $index ] ) ) { + $result[ $this->cols[ $index ] ] = $value; + } + if ( PHP_VERSION_ID < 80000 ) { + $result[] = $value; + } elseif ( ! isset( $result[ $index ] ) && ! array_key_exists( $index, $result ) ) { + // PHP 8+ PDO adds the numeric index only when no column-name + // key exists yet. Numeric column names can collide with the + // indexes of other columns. + $result[ $index ] = $value; + } + } + return $result; + } + } + + /** + * Build an associative row, de-duplicating column names like PDO + * (later columns win). + * + * @param array $row The raw row. + * @return array The associative row. + */ + private function assoc_row( $row ) { + $result = array(); + foreach ( $row as $index => $value ) { + $name = isset( $this->cols[ $index ] ) ? $this->cols[ $index ] : (string) $index; + $result[ $name ] = $this->stringify( $value ); + } + return $result; + } + + /** + * Apply PDO::ATTR_STRINGIFY_FETCHES to a value. + * + * @param mixed $value The value. + * @return mixed The (possibly stringified) value. + */ + private function stringify( $value ) { + if ( null === $value ) { + return null; + } + $legacy_emulated_fetches = PHP_VERSION_ID < 80100 && $this->pdo->getAttribute( PDO::ATTR_EMULATE_PREPARES ); + if ( $value instanceof WP_PHP_Engine_Blob ) { + if ( $legacy_emulated_fetches && '' === $value->bytes ) { + return null; + } + return $value->bytes; + } + if ( ( is_int( $value ) || is_float( $value ) || is_bool( $value ) ) && ( $legacy_emulated_fetches || $this->pdo->getAttribute( PDO::ATTR_STRINGIFY_FETCHES ) ) ) { + // PDO stringifies fetches using PHP value-to-string semantics + // (e.g. 0.0 becomes "0", not "0.0") on PHP 8.1+. + return $this->stringify_scalar_value( $value ); + } + return $value; + } + + /** + * Convert a scalar fetch value to the string returned by PDO SQLite. + * + * @param int|float|bool $value The scalar value. + * @return string The stringified value. + */ + private function stringify_scalar_value( $value ) { + if ( is_bool( $value ) ) { + return $value ? '1' : '0'; + } + if ( PHP_VERSION_ID < 80100 && is_float( $value ) && is_finite( $value ) && (float) (int) $value === $value ) { + return sprintf( '%.1F', $value ); + } + return (string) $value; + } +} diff --git a/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-pdo.php b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-pdo.php new file mode 100644 index 00000000..3daa2b3f --- /dev/null +++ b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-pdo.php @@ -0,0 +1,346 @@ + $pdo ) ); + * + * Supported DSNs: 'php-engine:', 'php-engine::memory:', and the + * SQLite forms 'sqlite:' / 'sqlite::memory:' for compatibility. + */ +class WP_PHP_Engine_PDO extends PDO { + /** + * The engine instance. + * + * @var WP_PHP_Engine + */ + private $engine; + + /** + * PDO attributes. + * + * @var array + */ + private $attributes = array(); + + /** + * Whether a PDO-level transaction is active (via beginTransaction). + * + * @var bool + */ + private $pdo_transaction = false; + + /** + * The last error info triple. + * + * @var array + */ + private $last_error_info = array( '00000', null, null ); + + /** + * Constructor. + * + * @param string $dsn The DSN. + * @param string|null $username Ignored. + * @param string|null $password Ignored. + * @param array|null $options PDO options. + * + * @throws PDOException When the DSN is not recognized. + */ + public function __construct( $dsn, $username = null, $password = null, $options = null ) { + // Intentionally do NOT call the parent constructor: all PDO methods + // used with this driver are overridden below. + $dsn_parts = explode( ':', $dsn, 2 ); + if ( count( $dsn_parts ) < 2 || ! in_array( $dsn_parts[0], array( 'php-engine', 'sqlite' ), true ) ) { + throw new PDOException( 'invalid data source name' ); + } + $path = $dsn_parts[1]; + $this->attributes = array( + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_BOTH, + PDO::ATTR_STRINGIFY_FETCHES => false, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_TIMEOUT => 0, + ); + + if ( is_array( $options ) && array_key_exists( PDO::ATTR_TIMEOUT, $options ) ) { + $this->attributes[ PDO::ATTR_TIMEOUT ] = $options[ PDO::ATTR_TIMEOUT ]; + } + $this->engine = new WP_PHP_Engine( $path, $this->attributes[ PDO::ATTR_TIMEOUT ] ); + + if ( is_array( $options ) ) { + foreach ( $options as $attribute => $value ) { + $this->setAttribute( $attribute, $value ); + } + } + } + + /** + * Get the engine instance. + * + * @return WP_PHP_Engine + */ + public function get_engine() { + return $this->engine; + } + + /** + * Prepare a statement. + * + * @param string $query The SQL string. + * @param array $options Ignored. + * @return WP_PHP_Engine_PDO_Statement|false + */ + #[\ReturnTypeWillChange] + public function prepare( $query, $options = array() ) { + try { + // Parse eagerly so that syntax errors surface on prepare(), + // like they do with real PDO SQLite. + $this->engine->parse( $query ); + } catch ( PDOException $e ) { + return $this->handle_error( $e ); + } + return new WP_PHP_Engine_PDO_Statement( $this, $this->engine, $query ); + } + + /** + * Execute a query directly. + * + * @param string $query The SQL string. + * @param int $fetch_mode The fetch mode. + * @param mixed ...$fetch_mode_args Additional fetch mode arguments. + * @return WP_PHP_Engine_PDO_Statement|false + */ + #[\ReturnTypeWillChange] + public function query( $query, $fetch_mode = null, ...$fetch_mode_args ) { + $statement = $this->prepare( $query ); + if ( false === $statement ) { + return false; + } + if ( false === $statement->execute() ) { + return false; + } + if ( null !== $fetch_mode ) { + $statement->setFetchMode( $fetch_mode, ...$fetch_mode_args ); + } + return $statement; + } + + /** + * Execute a statement and return the number of affected rows. + * + * @param string $query The SQL string. + * @return int|false + */ + #[\ReturnTypeWillChange] + public function exec( $query ) { + try { + $result = $this->engine->execute( $query ); + } catch ( PDOException $e ) { + return $this->handle_error( $e ); + } + return isset( $result['changes'] ) ? $result['changes'] : 0; + } + + /** + * Get the last inserted rowid. + * + * @param string|null $name Ignored. + * @return string + */ + #[\ReturnTypeWillChange] + public function lastInsertId( $name = null ) { + return (string) $this->engine->get_last_insert_rowid(); + } + + /** + * Begin a transaction. + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function beginTransaction() { + if ( $this->pdo_transaction || $this->engine->in_transaction() ) { + throw new PDOException( 'There is already an active transaction' ); + } + $this->engine->execute( 'BEGIN' ); + $this->pdo_transaction = true; + return true; + } + + /** + * Commit the current transaction. + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function commit() { + if ( ! $this->pdo_transaction && ! $this->engine->in_transaction() ) { + throw new PDOException( 'There is no active transaction' ); + } + $this->engine->execute( 'COMMIT' ); + $this->pdo_transaction = false; + return true; + } + + /** + * Roll back the current transaction. + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function rollBack() { + if ( ! $this->pdo_transaction && ! $this->engine->in_transaction() ) { + throw new PDOException( 'There is no active transaction' ); + } + $this->engine->execute( 'ROLLBACK' ); + $this->pdo_transaction = false; + return true; + } + + /** + * Check whether a transaction is active. + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function inTransaction() { + return $this->pdo_transaction || $this->engine->in_transaction(); + } + + /** + * Set an attribute. + * + * @param int $attribute The attribute. + * @param mixed $value The value. + * @return bool + */ + #[\ReturnTypeWillChange] + public function setAttribute( $attribute, $value ) { + $this->attributes[ $attribute ] = $value; + if ( PDO::ATTR_TIMEOUT === $attribute ) { + $this->engine->set_busy_timeout( $value ); + } + return true; + } + + /** + * Get an attribute. + * + * @param int $attribute The attribute. + * @return mixed + */ + #[\ReturnTypeWillChange] + public function getAttribute( $attribute ) { + switch ( $attribute ) { + case PDO::ATTR_SERVER_VERSION: + case PDO::ATTR_CLIENT_VERSION: + return WP_PHP_Engine::SQLITE_VERSION; + case PDO::ATTR_SERVER_INFO: + return ''; + case PDO::ATTR_DRIVER_NAME: + return 'sqlite'; + case PDO::ATTR_CONNECTION_STATUS: + return ''; + } + return isset( $this->attributes[ $attribute ] ) ? $this->attributes[ $attribute ] : null; + } + + /** + * Quote a value for use in a query. + * + * @param mixed $value The value. + * @param int $type The parameter type. + * @return string + */ + #[\ReturnTypeWillChange] + public function quote( $value, $type = PDO::PARAM_STR ) { + if ( PDO::PARAM_INT === $type && ( is_int( $value ) || ctype_digit( (string) $value ) ) ) { + return (string) $value; + } + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } + + /** + * Register a user-defined function (PDO SQLite API). + * + * @param string $name The SQL function name. + * @param callable $callback The implementation. + * @param int $num_args Ignored. + * @param int $flags Ignored. + * @return bool + */ + #[\ReturnTypeWillChange] + public function sqliteCreateFunction( $name, $callback, $num_args = -1, $flags = 0 ) { + $this->engine->register_function( $name, $callback ); + return true; + } + + /** + * Register a user-defined function (PDO\SQLite subclass API). + * + * @param string $name The SQL function name. + * @param callable $callback The implementation. + * @param int $num_args Ignored. + * @param int $flags Ignored. + * @return bool + */ + #[\ReturnTypeWillChange] + public function createFunction( $name, $callback, $num_args = -1, $flags = 0 ) { + return $this->sqliteCreateFunction( $name, $callback, $num_args, $flags ); + } + + /** + * Get the last error code. + * + * @return string|null + */ + #[\ReturnTypeWillChange] + public function errorCode() { + return $this->last_error_info[0]; + } + + /** + * Get the last error info. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function errorInfo() { + return $this->last_error_info; + } + + /** + * Handle an engine error according to the error mode. + * + * @param PDOException $e The exception. + * @return false + * @throws PDOException In ERRMODE_EXCEPTION mode. + */ + public function handle_error( $e ) { + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $this->last_error_info = isset( $e->errorInfo ) && is_array( $e->errorInfo ) + ? $e->errorInfo + : array( 'HY000', 1, $e->getMessage() ); + // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + if ( PDO::ERRMODE_EXCEPTION === $this->getAttribute( PDO::ATTR_ERRMODE ) ) { + throw $e; + } + if ( PDO::ERRMODE_WARNING === $this->getAttribute( PDO::ATTR_ERRMODE ) ) { + trigger_error( $e->getMessage(), E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions + } + return false; + } +} diff --git a/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-values.php b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-values.php new file mode 100644 index 00000000..5db3ecf9 --- /dev/null +++ b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine-values.php @@ -0,0 +1,514 @@ + PHP null + * - SQL INTEGER => PHP int + * - SQL REAL => PHP float + * - SQL TEXT => PHP string + * - SQL BLOB => PHP string (treated as TEXT unless noted) + * + * This class implements SQLite's dynamic type system: + * https://www.sqlite.org/datatype3.html + */ +class WP_PHP_Engine_Values { + const AFFINITY_TEXT = 'TEXT'; + const AFFINITY_NUMERIC = 'NUMERIC'; + const AFFINITY_INTEGER = 'INTEGER'; + const AFFINITY_REAL = 'REAL'; + const AFFINITY_BLOB = 'BLOB'; + + /** + * Determine the column affinity for a declared type. + * + * Implements the five rules from the SQLite documentation. + * + * @param string|null $type The declared column type. + * @return string The affinity. + */ + public static function affinity_for_type( $type ) { + if ( null === $type || '' === $type ) { + return self::AFFINITY_BLOB; + } + $type = strtoupper( $type ); + if ( false !== strpos( $type, 'INT' ) ) { + return self::AFFINITY_INTEGER; + } + if ( false !== strpos( $type, 'CHAR' ) || false !== strpos( $type, 'CLOB' ) || false !== strpos( $type, 'TEXT' ) ) { + return self::AFFINITY_TEXT; + } + if ( false !== strpos( $type, 'BLOB' ) ) { + return self::AFFINITY_BLOB; + } + if ( false !== strpos( $type, 'REAL' ) || false !== strpos( $type, 'FLOA' ) || false !== strpos( $type, 'DOUB' ) ) { + return self::AFFINITY_REAL; + } + return self::AFFINITY_NUMERIC; + } + + /** + * Get the SQLite storage type name of a value (as in typeof()). + * + * @param mixed $value The value. + * @return string One of 'null', 'integer', 'real', 'text', 'blob'. + */ + public static function type_of( $value ) { + if ( null === $value ) { + return 'null'; + } + if ( is_int( $value ) ) { + return 'integer'; + } + if ( is_float( $value ) ) { + return 'real'; + } + if ( $value instanceof WP_PHP_Engine_Blob ) { + return 'blob'; + } + return 'text'; + } + + /** + * Apply a column affinity to a value being stored or compared. + * + * @param mixed $value The value. + * @param string $affinity The affinity. + * @return mixed The coerced value. + */ + public static function apply_affinity( $value, $affinity ) { + if ( null === $value || $value instanceof WP_PHP_Engine_Blob ) { + return $value; + } + switch ( $affinity ) { + case self::AFFINITY_INTEGER: + case self::AFFINITY_NUMERIC: + if ( is_int( $value ) ) { + return $value; + } + if ( is_float( $value ) ) { + // Convert REAL to INTEGER when lossless. + if ( (float) (int) $value === $value && abs( $value ) < PHP_INT_MAX ) { + return (int) $value; + } + return $value; + } + if ( self::is_well_formed_number( $value ) ) { + $number = self::text_to_number( $value ); + if ( is_float( $number ) && (float) (int) $number === $number && abs( $number ) < PHP_INT_MAX ) { + return (int) $number; + } + return $number; + } + return $value; + case self::AFFINITY_REAL: + if ( is_int( $value ) ) { + return (float) $value; + } + if ( is_float( $value ) ) { + return $value; + } + if ( self::is_well_formed_number( $value ) ) { + return (float) $value; + } + return $value; + case self::AFFINITY_TEXT: + if ( is_int( $value ) || is_float( $value ) ) { + return self::to_text( $value ); + } + return $value; + default: + return $value; + } + } + + /** + * Check whether a string is a well-formed numeric literal that SQLite + * would convert under NUMERIC affinity. + * + * @param string $text The text value. + * @return bool Whether the text is a well-formed number. + */ + public static function is_well_formed_number( $text ) { + if ( ! is_string( $text ) ) { + return false; + } + return 1 === preg_match( '/^\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?\s*$/', $text ); + } + + /** + * Convert a text value to an int or float like SQLite's text-to-number. + * + * @param string $text The text value. + * @return int|float The numeric value. + */ + public static function text_to_number( $text ) { + $trimmed = trim( $text ); + if ( 1 === preg_match( '/^[+-]?\d+$/', $trimmed ) ) { + $float = (float) $trimmed; + if ( $float >= -9223372036854775808.0 && $float <= 9223372036854775807.0 ) { + $int = (int) $trimmed; + if ( (float) $int === $float || strlen( ltrim( $trimmed, '+-0' ) ) < 19 ) { + return $int; + } + } + return $float; + } + return (float) $trimmed; + } + + /** + * Coerce any value to a number for arithmetic (CAST AS NUMERIC prefix rules). + * + * @param mixed $value The value. + * @return int|float The numeric value. + */ + public static function to_numeric( $value ) { + if ( null === $value ) { + return 0; + } + if ( is_int( $value ) || is_float( $value ) ) { + return $value; + } + if ( $value instanceof WP_PHP_Engine_Blob ) { + $value = $value->bytes; + } + // Parse the longest numeric prefix. + if ( preg_match( '/^\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/', (string) $value, $m ) ) { + $prefix = trim( $m[0] ); + if ( 1 === preg_match( '/^[+-]?\d+$/', $prefix ) ) { + return self::text_to_number( $prefix ); + } + return (float) $prefix; + } + return 0; + } + + /** + * Render a value as TEXT using SQLite's conventions. + * + * @param mixed $value The value. + * @return string The text rendering. + */ + public static function to_text( $value ) { + if ( null === $value ) { + return ''; + } + if ( is_int( $value ) ) { + return (string) $value; + } + if ( is_float( $value ) ) { + return self::float_to_text( $value ); + } + if ( $value instanceof WP_PHP_Engine_Blob ) { + return $value->bytes; + } + return (string) $value; + } + + /** + * Render a float the way SQLite renders REAL values as text. + * + * @param float $value The float value. + * @return string The text rendering. + */ + public static function float_to_text( $value ) { + if ( is_infinite( $value ) ) { + return $value > 0 ? 'Inf' : '-Inf'; + } + if ( is_nan( $value ) ) { + return ''; // SQLite renders NaN as NULL; callers handle that. + } + // PHP 8 renders floats with the shortest round-trip representation, + // which matches SQLite's rendering in the common cases. SQLite always + // includes a decimal point or an exponent for REAL values. + $text = (string) $value; + // Normalize exponent form: 1e+21 => 1.0e+21. + if ( false !== stripos( $text, 'e' ) ) { + list( $mantissa, $exponent ) = preg_split( '/[eE]/', $text ); + if ( false === strpos( $mantissa, '.' ) ) { + $mantissa .= '.0'; + } + $sign = ''; + if ( '+' === $exponent[0] || '-' === $exponent[0] ) { + $sign = '-' === $exponent[0] ? '-' : '+'; + $exponent = substr( $exponent, 1 ); + } else { + $sign = '+'; + } + $exponent = ltrim( $exponent, '0' ); + if ( '' === $exponent ) { + $exponent = '0'; + } + return $mantissa . 'e' . $sign . ( strlen( $exponent ) < 2 ? '0' . $exponent : $exponent ); + } + if ( false === strpos( $text, '.' ) ) { + $text .= '.0'; + } + return $text; + } + + /** + * Compare two values using SQLite ordering rules. + * + * NULL < numeric values < text values. Within numerics, numeric order. + * Within text, the given collation applies. + * + * @param mixed $a The first value. + * @param mixed $b The second value. + * @param string $collate The collation for text comparison. + * @return int -1, 0, or 1. + */ + public static function compare( $a, $b, $collate = 'BINARY' ) { + $a_null = null === $a; + $b_null = null === $b; + if ( $a_null || $b_null ) { + if ( $a_null && $b_null ) { + return 0; + } + return $a_null ? -1 : 1; + } + + $a_blob = $a instanceof WP_PHP_Engine_Blob; + $b_blob = $b instanceof WP_PHP_Engine_Blob; + if ( $a_blob || $b_blob ) { + if ( $a_blob && $b_blob ) { + $result = strcmp( $a->bytes, $b->bytes ); + return $result < 0 ? -1 : ( $result > 0 ? 1 : 0 ); + } + return $a_blob ? 1 : -1; + } + + $a_numeric = is_int( $a ) || is_float( $a ); + $b_numeric = is_int( $b ) || is_float( $b ); + if ( $a_numeric && $b_numeric ) { + if ( $a == $b ) { // phpcs:ignore Universal.Operators.StrictComparisons -- Intentional cross-type numeric comparison, like in SQLite. + return 0; + } + return $a < $b ? -1 : 1; + } + if ( $a_numeric ) { + return -1; + } + if ( $b_numeric ) { + return 1; + } + + // Both text. + if ( 'NOCASE' === $collate ) { + $result = strcasecmp( $a, $b ); + } elseif ( 'RTRIM' === $collate ) { + $result = strcmp( rtrim( $a ), rtrim( $b ) ); + } else { + $result = strcmp( $a, $b ); + } + return $result < 0 ? -1 : ( $result > 0 ? 1 : 0 ); + } + + /** + * Apply comparison affinity rules to a pair of operands. + * + * See "Type Conversions Prior To Comparison" in the SQLite docs. + * + * @param mixed $left The left value. + * @param mixed $right The right value. + * @param string|null $left_affinity The left expression affinity, if any. + * @param string|null $right_affinity The right expression affinity, if any. + * @return array The two converted values. + */ + public static function apply_comparison_affinity( $left, $right, $left_affinity, $right_affinity ) { + $left_is_numeric_affinity = in_array( + $left_affinity, + array( self::AFFINITY_INTEGER, self::AFFINITY_REAL, self::AFFINITY_NUMERIC ), + true + ); + $right_is_numeric_affinity = in_array( + $right_affinity, + array( self::AFFINITY_INTEGER, self::AFFINITY_REAL, self::AFFINITY_NUMERIC ), + true + ); + + if ( $left_is_numeric_affinity && ! $right_is_numeric_affinity ) { + $right = self::apply_affinity( $right, self::AFFINITY_NUMERIC ); + } elseif ( $right_is_numeric_affinity && ! $left_is_numeric_affinity ) { + $left = self::apply_affinity( $left, self::AFFINITY_NUMERIC ); + } elseif ( self::AFFINITY_TEXT === $left_affinity && null === $right_affinity ) { + $right = self::apply_affinity( $right, self::AFFINITY_TEXT ); + } elseif ( self::AFFINITY_TEXT === $right_affinity && null === $left_affinity ) { + $left = self::apply_affinity( $left, self::AFFINITY_TEXT ); + } + return array( $left, $right ); + } + + /** + * Determine whether a value is truthy in a boolean context. + * + * @param mixed $value The value. + * @return bool Whether the value is truthy. + */ + public static function is_truthy( $value ) { + if ( null === $value ) { + return false; + } + if ( is_int( $value ) || is_float( $value ) ) { + return 0.0 != $value; // phpcs:ignore Universal.Operators.StrictComparisons -- Intentional cross-type numeric comparison, like in SQLite. + } + return 0.0 != self::to_numeric( $value ); // phpcs:ignore Universal.Operators.StrictComparisons -- Intentional cross-type numeric comparison, like in SQLite. + } + + /** + * Match a LIKE pattern. + * + * SQLite LIKE is case-insensitive for ASCII characters. + * + * @param string $pattern The LIKE pattern. + * @param string $value The value. + * @param string|null $escape The escape character. + * @return bool Whether the value matches. + */ + public static function like_match( $pattern, $value, $escape = null ) { + $regex = ''; + $length = strlen( $pattern ); + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( null !== $escape && $char === $escape && $i + 1 < $length ) { + $i += 1; + $regex .= preg_quote( $pattern[ $i ], '/' ); + continue; + } + if ( '%' === $char ) { + $regex .= '.*'; + } elseif ( '_' === $char ) { + $regex .= '.'; + } else { + $regex .= preg_quote( $char, '/' ); + } + } + return 1 === preg_match( '/^' . $regex . '$/isu', $value ) + || 1 === preg_match( '/^' . $regex . '$/is', $value ); + } + + /** + * Match a GLOB pattern (case-sensitive, with * ? [...] wildcards). + * + * @param string $pattern The GLOB pattern. + * @param string $value The value. + * @return bool Whether the value matches. + */ + public static function glob_match( $pattern, $value ) { + $regex = ''; + $length = strlen( $pattern ); + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( '*' === $char ) { + $regex .= '.*'; + } elseif ( '?' === $char ) { + $regex .= '.'; + } elseif ( '[' === $char ) { + // Character class: copy up to the closing bracket. + $end = strpos( $pattern, ']', $i + 2 ); + if ( false === $end ) { + $regex .= preg_quote( $char, '/' ); + continue; + } + $class = substr( $pattern, $i + 1, $end - $i - 1 ); + if ( '' !== $class && '^' === $class[0] ) { + $class = '^' . str_replace( array( '\\' ), array( '\\\\' ), substr( $class, 1 ) ); + } else { + $class = str_replace( array( '\\' ), array( '\\\\' ), $class ); + } + $regex .= '[' . $class . ']'; + $i = $end; + } else { + $regex .= preg_quote( $char, '/' ); + } + } + return 1 === preg_match( '/^' . $regex . '$/s', $value ); + } + + /** + * Cast a value using CAST() semantics. + * + * @param mixed $value The value. + * @param string $type The target type name. + * @return mixed The cast value. + */ + public static function cast( $value, $type ) { + if ( null === $value ) { + return null; + } + $affinity = self::affinity_for_type( $type ); + if ( self::AFFINITY_BLOB === $affinity && false !== stripos( (string) $type, 'BLOB' ) ) { + return $value instanceof WP_PHP_Engine_Blob ? $value : new WP_PHP_Engine_Blob( self::to_text( $value ) ); + } + if ( $value instanceof WP_PHP_Engine_Blob ) { + $value = $value->bytes; + } + switch ( $affinity ) { + case self::AFFINITY_INTEGER: + if ( is_int( $value ) ) { + return $value; + } + if ( is_float( $value ) ) { + if ( $value >= 9223372036854775807.0 ) { + return PHP_INT_MAX; + } + if ( $value <= -9223372036854775808.0 ) { + return PHP_INT_MIN; + } + return (int) $value; + } + // Longest integer prefix (through a float prefix truncates). + $number = self::to_numeric( $value ); + return is_float( $number ) ? (int) $number : $number; + case self::AFFINITY_REAL: + return (float) self::to_numeric( $value ); + case self::AFFINITY_NUMERIC: + if ( is_int( $value ) || is_float( $value ) ) { + return $value; + } + if ( self::is_well_formed_number( $value ) ) { + $number = self::text_to_number( $value ); + if ( is_float( $number ) && (float) (int) $number === $number && abs( $number ) < PHP_INT_MAX ) { + return (int) $number; + } + return $number; + } + return self::to_numeric( $value ); + case self::AFFINITY_TEXT: + case self::AFFINITY_BLOB: + return self::to_text( $value ); + } + return $value; + } +} + +/** + * A BLOB value wrapper, distinguishing SQL BLOBs from TEXT. + */ +class WP_PHP_Engine_Blob { + /** + * The raw bytes. + * + * @var string + */ + public $bytes; + + /** + * Constructor. + * + * @param string $bytes The raw bytes. + */ + public function __construct( $bytes ) { + $this->bytes = $bytes; + } +} diff --git a/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine.php b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine.php new file mode 100644 index 00000000..fb11b6dd --- /dev/null +++ b/packages/mysql-on-sqlite/src/php-engine/class-wp-php-engine.php @@ -0,0 +1,3431 @@ + array( lowercase name => table ), + * 'indexes' => array( lowercase name => index ), + * 'triggers' => array( lowercase name => trigger ), + * 'views' => array( lowercase name => view ), + * 'sequences' => array( table name => int ), + * ) + * + * @var array + */ + private $db; + + /** + * The transaction stack: a list of array( 'name' => ?string, 'db' => array ). + * + * @var array + */ + private $transaction_stack = array(); + + /** + * The database file path, or null for in-memory databases. + * + * @var string|null + */ + private $path; + + /** + * User-defined functions: lowercase name => callable. + * + * @var array + */ + private $user_functions = array(); + + /** + * The built-in function library. + * + * @var WP_PHP_Engine_Functions + */ + private $functions; + + /** + * The last INSERT rowid. + * + * @var int + */ + private $last_insert_rowid = 0; + + /** + * The number of rows changed by the last DML statement. + * + * @var int + */ + private $changes = 0; + + /** + * The total number of rows changed in this session. + * + * @var int + */ + private $total_changes = 0; + + /** + * Whether foreign key enforcement is enabled. + * + * @var bool + */ + private $foreign_keys_enabled = false; + + /** + * The trigger execution depth (recursive triggers are disabled). + * + * @var int + */ + private $trigger_depth = 0; + + /** + * Extra scope frames for trigger body evaluation (NEW/OLD references). + * + * @var array + */ + private $extra_scopes = array(); + + /** + * A small cache of parsed statements, keyed by SQL string. + * + * @var array + */ + private $statement_cache = array(); + + /** + * Pragmas that store a value and return it when queried. + * + * @var array + */ + private $pragma_values = array(); + + /** + * The open handle of the database file (for locking and I/O). + * + * @var resource|null + */ + private $file_handle; + + /** + * The generation token of the last loaded or saved file state. + * + * Every save writes a new unique token to the file header. When the + * token on disk no longer matches, another process has saved in the + * meantime and the in-memory state needs to be reloaded. + * + * @var string|null + */ + private $file_generation; + + /** + * Whether this connection holds the exclusive write lock. + * + * @var bool + */ + private $write_lock_held = false; + + /** + * Seconds to wait when acquiring a file lock before reporting SQLITE_BUSY. + * + * @var float + */ + private $busy_timeout = 0.0; + + /** + * Constructor. + * + * @param string|null $path The database file path, or null/':memory:'. + * @param int|float $busy_timeout Seconds to wait before reporting SQLITE_BUSY. + * + * @throws WP_PHP_Engine_SQL_Exception When the database file cannot be used. + */ + public function __construct( $path = null, $busy_timeout = 0.0 ) { + $this->path = null === $path || ':memory:' === $path || '' === $path ? null : $path; + $this->busy_timeout = max( 0.0, (float) $busy_timeout ); + $this->functions = new WP_PHP_Engine_Functions( $this ); + $this->db = array( + 'tables' => array(), + 'indexes' => array(), + 'triggers' => array(), + 'views' => array(), + 'sequences' => array(), + ); + if ( null !== $this->path ) { + $this->open_database_file(); + } + } + + /** + * Destructor: release the file handle. + */ + public function __destruct() { + if ( null !== $this->file_handle ) { + flock( $this->file_handle, LOCK_UN ); + fclose( $this->file_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + } + + /** + * Open (or create) the database file and load its contents. + * + * @throws WP_PHP_Engine_SQL_Exception When the file cannot be opened or + * is not a WP_PHP_Engine database. + */ + private function open_database_file() { + $handle = fopen( $this->path, 'c+b' ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( false === $handle ) { + throw new WP_PHP_Engine_SQL_Exception( 'unable to open database file', 'HY000', 14 ); + } + $this->file_handle = $handle; + + $this->acquire_shared_lock(); + try { + $this->reload_if_changed(); + } finally { + flock( $this->file_handle, LOCK_UN ); + } + } + + /* + * ---------------------------------------------------------------------- + * Public API (used by the PDO facade). + * ---------------------------------------------------------------------- + */ + + /** + * Statement types that can modify the database. + * + * @var array + */ + private static $write_statement_types = array( + 'insert' => true, + 'update' => true, + 'delete' => true, + 'create_table' => true, + 'create_table_as' => true, + 'create_index' => true, + 'create_trigger' => true, + 'create_view' => true, + 'drop' => true, + 'alter_rename_table' => true, + 'alter_rename_column' => true, + 'alter_add_column' => true, + 'alter_drop_column' => true, + 'begin' => true, + 'commit' => true, + 'rollback' => true, + 'rollback_to' => true, + 'savepoint' => true, + 'release' => true, + ); + + /** + * Execute an SQL string with bound parameters. + * + * For file-backed databases, write statements run under an exclusive + * file lock with a fresh state reload, so that concurrent requests + * serialize their read-modify-write cycles instead of clobbering each + * other. A transaction holds the exclusive lock from BEGIN until + * COMMIT or ROLLBACK, like SQLite does. + * + * @param string $sql The SQL string. + * @param array $params The bound parameter values. + * @return array The result: array( 'cols', 'rows', 'decl', 'changes' ). + * @throws WP_PHP_Engine_SQL_Exception When execution fails. + */ + public function execute( $sql, $params = array() ) { + $statements = $this->parse( $sql ); + $result = array( + 'cols' => array(), + 'rows' => array(), + 'decl' => array(), + 'changes' => 0, + ); + + $is_write = false; + foreach ( $statements as $statement ) { + if ( isset( self::$write_statement_types[ $statement['t'] ] ) ) { + $is_write = true; + break; + } + } + + // Synchronize file-backed databases with other processes. + if ( null !== $this->file_handle && ! $this->in_transaction() ) { + if ( $is_write ) { + $this->acquire_write_lock(); + $this->reload_if_changed(); + } else { + $this->acquire_shared_lock(); + try { + $this->reload_if_changed(); + } finally { + flock( $this->file_handle, LOCK_UN ); + } + } + } + + try { + foreach ( $statements as $statement ) { + $result = $this->execute_statement( $statement, $params ); + } + } catch ( WP_PHP_Engine_Constraint_Exception $e ) { + throw $e->inner; + } finally { + // Persist and release the lock, unless a transaction is open. + if ( null !== $this->file_handle && $is_write && ! $this->in_transaction() ) { + $this->save_to_disk(); + $this->release_write_lock(); + } + } + return $result; + } + + /** + * Parse an SQL string (with caching). + * + * @param string $sql The SQL string. + * @return array The statement AST nodes. + */ + public function parse( $sql ) { + if ( isset( $this->statement_cache[ $sql ] ) ) { + return $this->statement_cache[ $sql ]; + } + $parser = new WP_PHP_Engine_Parser(); + $statements = $parser->parse( $sql ); + if ( count( $this->statement_cache ) > 512 ) { + $this->statement_cache = array(); + } + $this->statement_cache[ $sql ] = $statements; + return $statements; + } + + /** + * Register a user-defined function. + * + * @param string $name The function name. + * @param callable $callback The implementation. + */ + public function register_function( $name, $callback ) { + $this->user_functions[ strtolower( $name ) ] = $callback; + } + + /** + * Check whether a user-defined function exists. + * + * @param string $name The lowercase function name. + * @return bool Whether the function exists. + */ + public function has_user_function( $name ) { + return isset( $this->user_functions[ $name ] ); + } + + /** + * Call a user-defined function. + * + * @param string $name The lowercase function name. + * @param array $args The arguments. + * @return mixed The result. + */ + public function call_user_function( $name, $args ) { + $result = call_user_func_array( $this->user_functions[ $name ], $args ); + if ( is_bool( $result ) ) { + return $result ? 1 : 0; + } + return $result; + } + + /** + * Get the built-in function library. + * + * @return WP_PHP_Engine_Functions + */ + public function get_functions() { + return $this->functions; + } + + /** + * Get the last INSERT rowid. + * + * @return int + */ + public function get_last_insert_rowid() { + return $this->last_insert_rowid; + } + + /** + * Get the number of rows changed by the last DML statement. + * + * @return int + */ + public function get_changes() { + return $this->changes; + } + + /** + * Get the total number of rows changed in this session. + * + * @return int + */ + public function get_total_changes() { + return $this->total_changes; + } + + /** + * Check whether a transaction is active. + * + * @return bool + */ + public function in_transaction() { + return count( $this->transaction_stack ) > 0; + } + + /** + * Get the current UTC timestamp in the requested format. + * + * @param string $fn CURRENT_TIMESTAMP, CURRENT_DATE, or CURRENT_TIME. + * @return string The formatted timestamp. + */ + public function current_timestamp( $keyword ) { + switch ( $keyword ) { + case 'CURRENT_DATE': + return gmdate( 'Y-m-d' ); + case 'CURRENT_TIME': + return gmdate( 'H:i:s' ); + default: + return gmdate( 'Y-m-d H:i:s' ); + } + } + + /** + * Get a table definition by lowercase name. + * + * Temporary tables shadow regular tables with the same name. + * + * @param string $lower The lowercase table name. + * @return array|null The table, or null. + */ + public function get_table( $lower ) { + if ( isset( $this->db['tables'][ 'temp.' . $lower ] ) ) { + return $this->db['tables'][ 'temp.' . $lower ]; + } + return isset( $this->db['tables'][ $lower ] ) ? $this->db['tables'][ $lower ] : null; + } + + /** + * Resolve a lowercase table name to its storage key. + * + * Temporary tables are stored with a "temp." key prefix and shadow + * regular tables with the same name. + * + * @param string $lower The lowercase table name. + * @return string|null The storage key, or null when not found. + */ + public function resolve_table_key( $lower ) { + if ( isset( $this->db['tables'][ 'temp.' . $lower ] ) ) { + return 'temp.' . $lower; + } + if ( isset( $this->db['tables'][ $lower ] ) ) { + return $lower; + } + return null; + } + + /** + * Strip the schema prefix from a table storage key. + * + * @param string $key The storage key. + * @return string The bare lowercase table name. + */ + private static function bare_table_name( $key ) { + return 0 === strpos( $key, 'temp.' ) ? substr( $key, 5 ) : $key; + } + + /** + * Get a view definition by lowercase name. + * + * @param string $lower The lowercase view name. + * @return array|null The view, or null. + */ + public function get_view( $lower ) { + return isset( $this->db['views'][ $lower ] ) ? $this->db['views'][ $lower ] : null; + } + + /** + * Get the extra scope frames (for trigger NEW/OLD references). + * + * @return array + */ + public function get_extra_scopes() { + return $this->extra_scopes; + } + + /* + * ---------------------------------------------------------------------- + * Statement execution. + * ---------------------------------------------------------------------- + */ + + /** + * Execute a single parsed statement. + * + * @param array $statement The statement AST node. + * @param array $params The bound parameter values. + * @return array The result. + */ + private function execute_statement( $statement, $params ) { + $evaluator = new WP_PHP_Engine_Evaluator( $this, $params ); + $evaluator->scopes = $this->extra_scopes; + + switch ( $statement['t'] ) { + case 'select': + $result = $evaluator->select( $statement ); + $result['changes'] = 0; + return $result; + + case 'insert': + return $this->execute_insert( $statement, $evaluator ); + + case 'update': + return $this->execute_update( $statement, $evaluator ); + + case 'delete': + return $this->execute_delete( $statement, $evaluator ); + + case 'create_table': + return $this->execute_create_table( $statement, $evaluator ); + + case 'create_table_as': + return $this->execute_create_table_as( $statement, $evaluator ); + + case 'create_index': + return $this->execute_create_index( $statement, $evaluator ); + + case 'create_trigger': + return $this->execute_create_trigger( $statement ); + + case 'create_view': + return $this->execute_create_view( $statement ); + + case 'drop': + return $this->execute_drop( $statement ); + + case 'alter_rename_table': + return $this->execute_alter_rename_table( $statement ); + + case 'alter_rename_column': + return $this->execute_alter_rename_column( $statement ); + + case 'alter_add_column': + return $this->execute_alter_add_column( $statement, $evaluator ); + + case 'alter_drop_column': + return $this->execute_alter_drop_column( $statement ); + + case 'pragma': + return $this->execute_pragma( $statement ); + + case 'begin': + if ( $this->in_explicit_transaction ) { + throw new WP_PHP_Engine_SQL_Exception( 'cannot start a transaction within a transaction' ); + } + $this->in_explicit_transaction = true; + $this->transaction_stack[] = array( + 'name' => null, + 'db' => $this->db, + ); + return $this->empty_result(); + + case 'commit': + if ( ! $this->in_explicit_transaction && 0 === count( $this->transaction_stack ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'cannot commit - no transaction is active' ); + } + $this->transaction_stack = array(); + $this->in_explicit_transaction = false; + return $this->empty_result(); + + case 'rollback': + if ( ! $this->in_explicit_transaction && 0 === count( $this->transaction_stack ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'cannot rollback - no transaction is active' ); + } + $this->db = $this->transaction_stack[0]['db']; + $this->transaction_stack = array(); + $this->in_explicit_transaction = false; + return $this->empty_result(); + + case 'savepoint': + $this->transaction_stack[] = array( + 'name' => strtolower( $statement['name'] ), + 'db' => $this->db, + ); + return $this->empty_result(); + + case 'release': + $index = $this->find_savepoint( $statement['name'] ); + if ( null === $index ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such savepoint: ' . $statement['name'] ); + } + $this->transaction_stack = array_slice( $this->transaction_stack, 0, $index ); + return $this->empty_result(); + + case 'rollback_to': + $index = $this->find_savepoint( $statement['name'] ); + if ( null === $index ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such savepoint: ' . $statement['name'] ); + } + $this->db = $this->transaction_stack[ $index ]['db']; + $this->transaction_stack = array_slice( $this->transaction_stack, 0, $index + 1 ); + return $this->empty_result(); + + case 'analyze': + if ( null !== $statement['name'] ) { + $lower = strtolower( $statement['name'] ); + if ( null === $this->resolve_table_key( $lower ) + && ! isset( $this->db['indexes'][ $lower ] ) + && ! isset( $this->db['indexes'][ 'temp.' . $lower ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['name'] ); + } + } + return $this->empty_result(); + + case 'noop': + return $this->empty_result(); + } + + throw new WP_PHP_Engine_SQL_Exception( 'unsupported statement' ); + } + + /** + * Whether an explicit BEGIN transaction is active. + * + * @var bool + */ + private $in_explicit_transaction = false; + + /** + * Find the last savepoint with the given name. + * + * @param string $name The savepoint name. + * @return int|null The stack index, or null. + */ + private function find_savepoint( $name ) { + $lower = strtolower( $name ); + for ( $i = count( $this->transaction_stack ) - 1; $i >= 0; $i-- ) { + if ( $this->transaction_stack[ $i ]['name'] === $lower ) { + return $i; + } + } + return null; + } + + /** + * Build an empty result. + * + * @return array + */ + private function empty_result() { + return array( + 'cols' => array(), + 'rows' => array(), + 'decl' => array(), + 'changes' => 0, + ); + } + + /** + * Run a write operation with statement-level atomicity. + * + * @param callable $callback The operation. + * @return array The result. + */ + private function atomic( $callback ) { + $snapshot = $this->db; + try { + $result = $callback(); + } catch ( Exception $e ) { + $this->db = $snapshot; + throw $e; + } + return $result; + } + + /* + * ---------------------------------------------------------------------- + * INSERT. + * ---------------------------------------------------------------------- + */ + + /** + * Execute an INSERT or REPLACE statement. + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + private function execute_insert( $statement, $evaluator ) { + $engine = $this; + return $this->atomic( + function () use ( $statement, $evaluator, $engine ) { + return $engine->do_insert( $statement, $evaluator ); + } + ); + } + + /** + * The INSERT implementation (public for closure access on PHP 7.x). + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + public function do_insert( $statement, $evaluator ) { + if ( 'sqlite_sequence' === strtolower( $statement['tbl'] ) && $this->sequence_table_exists( isset( $statement['db'] ) ? $statement['db'] : null ) ) { + return $this->insert_sqlite_sequence( $statement, $evaluator ); + } + + $lower = $this->resolve_table_key( strtolower( $statement['tbl'] ) ); + if ( null === $lower ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['tbl'] ); + } + $table = $this->db['tables'][ $lower ]; + + // Resolve the source rows. + $src = $statement['src']; + if ( 'default_values' === $src['t'] ) { + $source_rows = array( null ); // One row of all defaults. + } else { + if ( ! empty( $statement['with'] ) ) { + $src['with'] = array_merge( $statement['with'], isset( $src['with'] ) ? $src['with'] : array() ); + } + $result = $evaluator->select( $src ); + $source_rows = $result['rows']; + } + + // Resolve target columns. + $column_lowers = array_keys( $table['columns'] ); + if ( null !== $statement['cols'] ) { + $targets = array(); + foreach ( $statement['cols'] as $col ) { + $col_lower = strtolower( $col ); + if ( ! isset( $table['columns'][ $col_lower ] ) + && ! in_array( $col_lower, array( 'rowid', '_rowid_', 'oid' ), true ) ) { + throw new WP_PHP_Engine_SQL_Exception( + 'table ' . $table['name'] . ' has no column named ' . $col + ); + } + $targets[] = $col_lower; + } + } else { + $targets = $column_lowers; + } + + $changes = 0; + foreach ( $source_rows as $source_row ) { + if ( null !== $source_row && count( $source_row ) !== count( $targets ) ) { + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'table %s has %d columns but %d values were supplied', $table['name'], count( $targets ), count( $source_row ) ) + ); + } + + // Build the candidate row. + $candidate = array(); + $explicit_rowid = null; + if ( null !== $source_row ) { + foreach ( $targets as $position => $col_lower ) { + if ( ! isset( $table['columns'][ $col_lower ] ) ) { + $explicit_rowid = $source_row[ $position ]; // Bare rowid target. + continue; + } + $candidate[ $col_lower ] = $source_row[ $position ]; + } + } + foreach ( $column_lowers as $col_lower ) { + if ( ! array_key_exists( $col_lower, $candidate ) ) { + $candidate[ $col_lower ] = $this->column_default_value( $table['columns'][ $col_lower ], $evaluator ); + } + } + + try { + $changes += $this->insert_row( $lower, $candidate, $explicit_rowid, $statement, $evaluator ); + } catch ( WP_PHP_Engine_Constraint_Exception $e ) { + if ( 'IGNORE' === $statement['or'] ) { + continue; + } + throw $e->inner; + } + } + + $this->changes = $changes; + $this->total_changes += $changes; + $result = $this->empty_result(); + $result['changes'] = $changes; + return $result; + } + + /** + * Compute the default value of a column. + * + * @param array $column The column definition. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return mixed The default value. + */ + private function column_default_value( $column, $evaluator ) { + if ( ! $column['has_default'] || null === $column['default'] ) { + return null; + } + return $evaluator->eval( $column['default'], array() ); + } + + /** + * Insert a single row, enforcing constraints and firing triggers. + * + * @param string $lower The lowercase table name. + * @param array $candidate The candidate row (lowercase col => value). + * @param mixed $explicit_rowid An explicitly specified bare rowid. + * @param array $statement The INSERT statement node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return int The number of affected rows (0 or 1). + * @throws WP_PHP_Engine_Constraint_Exception On constraint violations (catchable for OR IGNORE). + */ + private function insert_row( $lower, $candidate, $explicit_rowid, $statement, $evaluator ) { + $table = $this->db['tables'][ $lower ]; + + // Apply column affinities and STRICT typing. + $candidate = $this->apply_row_affinity( $table, $candidate ); + + // Resolve the rowid. + $rowid_alias = $table['rowid_alias']; + $rowid = null; + if ( null !== $rowid_alias && null !== $candidate[ $rowid_alias ] ) { + $value = $candidate[ $rowid_alias ]; + if ( ! is_int( $value ) ) { + if ( is_float( $value ) && (float) (int) $value === $value ) { + $value = (int) $value; + } elseif ( is_string( $value ) && WP_PHP_Engine_Values::is_well_formed_number( $value ) ) { + $number = WP_PHP_Engine_Values::text_to_number( $value ); + if ( is_int( $number ) ) { + $value = $number; + } + } + } + if ( ! is_int( $value ) ) { + throw $this->constraint( 'datatype mismatch', 'HY000', 20 ); + } + $rowid = $value; + $candidate[ $rowid_alias ] = $value; + } + + if ( null === $rowid && null !== $explicit_rowid ) { + // An explicitly targeted bare "rowid" column. + $rowid = (int) $explicit_rowid; + if ( null !== $rowid_alias && null === $candidate[ $rowid_alias ] ) { + $candidate[ $rowid_alias ] = $rowid; + } + } + + if ( null === $rowid ) { + $max = 0; + if ( count( $table['rows'] ) > 0 ) { + $max = max( array_keys( $table['rows'] ) ); + } + if ( $table['autoincrement'] ) { + $sequence_key = $this->sequence_key_for_table( $table ); + $sequence = isset( $this->db['sequences'][ $sequence_key ] ) ? $this->db['sequences'][ $sequence_key ] : 0; + $rowid = max( $max, $sequence ) + 1; + } else { + $rowid = $max + 1; + } + if ( null !== $rowid_alias ) { + $candidate[ $rowid_alias ] = $rowid; + } + } + + // Check NOT NULL constraints. + foreach ( $table['columns'] as $col_lower => $column ) { + if ( $column['notnull'] && null === $candidate[ $col_lower ] ) { + throw $this->constraint( + 'NOT NULL constraint failed: ' . $table['name'] . '.' . $column['name'], + '23000', + 19, + 'Integrity constraint violation' + ); + } + } + + // Check CHECK constraints. + $this->check_constraints( $table, $candidate, $rowid, $evaluator ); + + // Check the rowid and UNIQUE constraints. + $conflict_rowid = null; + $conflict_cols = null; + if ( isset( $table['rows'][ $rowid ] ) ) { + $conflict_rowid = $rowid; + $conflict_cols = array( null !== $rowid_alias ? $table['columns'][ $rowid_alias ]['name'] : 'rowid' ); + } else { + $found = $this->find_unique_conflict( $lower, $candidate, null ); + if ( null !== $found ) { + $conflict_rowid = $found[0]; + $conflict_cols = $found[1]; + } + } + + if ( null !== $conflict_rowid ) { + $or = $statement['or']; + if ( 'REPLACE' === $or ) { + // Delete all conflicting rows, then insert. + while ( null !== $conflict_rowid ) { + $this->delete_row( $lower, $conflict_rowid, $evaluator, false ); + $table = $this->db['tables'][ $lower ]; + $conflict_rowid = isset( $table['rows'][ $rowid ] ) ? $rowid : null; + if ( null === $conflict_rowid ) { + $found = $this->find_unique_conflict( $lower, $candidate, null ); + $conflict_rowid = null !== $found ? $found[0] : null; + } + } + } elseif ( null !== $statement['upsert'] ) { + $upsert = $statement['upsert']; + if ( 'nothing' === $upsert['do'] ) { + return 0; + } + return $this->upsert_update( $lower, $conflict_rowid, $candidate, $upsert, $evaluator ); + } else { + $names = array(); + foreach ( $conflict_cols as $col ) { + $names[] = $table['name'] . '.' . $col; + } + throw $this->constraint( + 'UNIQUE constraint failed: ' . implode( ', ', $names ), + '23000', + 19, + 'Integrity constraint violation' + ); + } + } + + // Foreign key checks (child side). + $this->db['tables'][ $lower ]['rows'][ $rowid ] = $candidate; + try { + $this->check_foreign_keys_child( $lower, $candidate ); + } catch ( Exception $e ) { + unset( $this->db['tables'][ $lower ]['rows'][ $rowid ] ); + throw $e; + } + + // Update the AUTOINCREMENT sequence. + if ( $table['autoincrement'] ) { + $sequence_key = $this->sequence_key_for_table( $table ); + $sequence = isset( $this->db['sequences'][ $sequence_key ] ) ? $this->db['sequences'][ $sequence_key ] : 0; + if ( $rowid > $sequence ) { + $this->db['sequences'][ $sequence_key ] = $rowid; + } + } + + $this->last_insert_rowid = $rowid; + + // Fire AFTER INSERT triggers. + $this->fire_triggers( $lower, 'INSERT', null, $candidate, $rowid, null ); + + return 1; + } + + /** + * Perform the DO UPDATE part of an upsert. + * + * @param string $lower The lowercase table name. + * @param int $conflict_rowid The rowid of the conflicting row. + * @param array $candidate The proposed (excluded) row. + * @param array $upsert The upsert AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return int The number of affected rows (0 or 1). + */ + private function upsert_update( $lower, $conflict_rowid, $candidate, $upsert, $evaluator ) { + $table = $this->db['tables'][ $lower ]; + $existing = $table['rows'][ $conflict_rowid ]; + + // Scope: the table row (current values) plus the "excluded" row. + $table_slot = $this->make_table_slot( $table, $existing, $conflict_rowid, $lower ); + $excluded_slot = $this->make_table_slot( $table, $candidate, null, 'excluded' ); + $frame = array( $table_slot, $excluded_slot ); + + if ( null !== $upsert['where'] ) { + if ( ! WP_PHP_Engine_Values::is_truthy( $evaluator->eval( $upsert['where'], $frame ) ) ) { + return 0; + } + } + + $new_row = $existing; + foreach ( $upsert['set'] as $assignment ) { + $col_lower = strtolower( $assignment['col'] ); + if ( ! isset( $table['columns'][ $col_lower ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such column: ' . $assignment['col'] ); + } + $new_row[ $col_lower ] = $evaluator->eval( $assignment['e'], $frame ); + } + + $this->update_row( $lower, $conflict_rowid, $new_row, $evaluator, array_map( 'strtolower', array_column( $upsert['set'], 'col' ) ) ); + + return 1; + } + + /* + * ---------------------------------------------------------------------- + * UPDATE. + * ---------------------------------------------------------------------- + */ + + /** + * Execute an UPDATE statement. + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + private function execute_update( $statement, $evaluator ) { + $engine = $this; + return $this->atomic( + function () use ( $statement, $evaluator, $engine ) { + return $engine->do_update( $statement, $evaluator ); + } + ); + } + + /** + * The UPDATE implementation. + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + public function do_update( $statement, $evaluator ) { + if ( 'sqlite_sequence' === strtolower( $statement['tbl'] ) && $this->sequence_table_exists( isset( $statement['db'] ) ? $statement['db'] : null ) ) { + return $this->update_sqlite_sequence( $statement, $evaluator ); + } + + $lower = $this->resolve_table_key( strtolower( $statement['tbl'] ) ); + if ( null === $lower ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['tbl'] ); + } + $table = $this->db['tables'][ $lower ]; + $alias = null !== $statement['alias'] ? strtolower( $statement['alias'] ) : self::bare_table_name( $lower ); + + if ( ! empty( $statement['with'] ) ) { + foreach ( $statement['with'] as $name => $cte ) { + $evaluator->ctes[ $name ] = $cte; + } + } + + // Find matching rows first (the row set must not change mid-update). + // UPDATE ... FROM joins additional tables into the row scope. + $from = null; + if ( null !== $statement['from'] ) { + $from = $evaluator->resolve_update_from( $statement['from'] ); + } + + $matching = array(); + $scan_rows = $table['rows']; + ksort( $scan_rows ); + foreach ( $scan_rows as $rowid => $row ) { + $slot = $this->make_table_slot( $table, $row, $rowid, $alias ); + if ( null === $from ) { + $frame = array( $slot ); + if ( null === $statement['where'] || WP_PHP_Engine_Values::is_truthy( $evaluator->eval( $statement['where'], $frame ) ) ) { + $matching[] = array( $rowid, null ); + } + } else { + // The first matching combination provides the SET values. + foreach ( $from['frames'] as $from_frame ) { + $frame = array_merge( array( $slot ), $from_frame ); + if ( null === $statement['where'] || WP_PHP_Engine_Values::is_truthy( $evaluator->eval( $statement['where'], $frame ) ) ) { + $matching[] = array( $rowid, $frame ); + break; + } + } + } + } + + $set_columns = array(); + foreach ( $statement['set'] as $assignment ) { + if ( isset( $assignment['cols'] ) ) { + foreach ( $assignment['cols'] as $col ) { + $set_columns[] = strtolower( $col ); + } + } else { + $set_columns[] = strtolower( $assignment['col'] ); + } + } + + $changes = 0; + foreach ( $matching as $match ) { + list( $rowid, $frame ) = $match; + $table = $this->db['tables'][ $lower ]; + if ( ! isset( $table['rows'][ $rowid ] ) ) { + continue; // Deleted by a trigger or cascade. + } + $row = $table['rows'][ $rowid ]; + if ( null === $frame ) { + $frame = array( $this->make_table_slot( $table, $row, $rowid, $alias ) ); + } + + $new_row = $row; + foreach ( $statement['set'] as $assignment ) { + // Multi-column assignment: SET (a, b) = (SELECT ...). + if ( isset( $assignment['cols'] ) ) { + $values = $evaluator->eval_row_subquery( $assignment['e'], $frame ); + foreach ( $assignment['cols'] as $position => $col ) { + $col_lower = strtolower( $col ); + if ( ! isset( $table['columns'][ $col_lower ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such column: ' . $col ); + } + $new_row[ $col_lower ] = null !== $values && array_key_exists( $position, $values ) ? $values[ $position ] : null; + } + continue; + } + $col_lower = strtolower( $assignment['col'] ); + if ( ! isset( $table['columns'][ $col_lower ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such column: ' . $assignment['col'] ); + } + $new_row[ $col_lower ] = $evaluator->eval( $assignment['e'], $frame ); + } + + $this->update_row( $lower, $rowid, $new_row, $evaluator, $set_columns ); + $changes += 1; + } + + $this->changes = $changes; + $this->total_changes += $changes; + $result = $this->empty_result(); + $result['changes'] = $changes; + return $result; + } + + /** + * Update a single row, enforcing constraints and firing triggers. + * + * @param string $lower The lowercase table name. + * @param int $rowid The rowid. + * @param array $new_row The new row values. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @param array $set_columns The lowercase names of assigned columns. + */ + private function update_row( $lower, $rowid, $new_row, $evaluator, $set_columns ) { + $table = $this->db['tables'][ $lower ]; + $old_row = $table['rows'][ $rowid ]; + + // Apply column affinities and STRICT typing. + $new_row = $this->apply_row_affinity( $table, $new_row ); + + // A change of the rowid alias column moves the row. + $new_rowid = $rowid; + $rowid_alias = $table['rowid_alias']; + if ( null !== $rowid_alias && $new_row[ $rowid_alias ] !== $old_row[ $rowid_alias ] ) { + $value = $new_row[ $rowid_alias ]; + if ( is_float( $value ) && (float) (int) $value === $value ) { + $value = (int) $value; + } elseif ( is_string( $value ) && WP_PHP_Engine_Values::is_well_formed_number( $value ) ) { + $number = WP_PHP_Engine_Values::text_to_number( $value ); + if ( is_int( $number ) ) { + $value = $number; + } + } + if ( ! is_int( $value ) ) { + throw $this->constraint( 'datatype mismatch', 'HY000', 20 ); + } + $new_rowid = $value; + $new_row[ $rowid_alias ] = $value; + } + + // NOT NULL constraints. + foreach ( $table['columns'] as $col_lower => $column ) { + if ( $column['notnull'] && null === $new_row[ $col_lower ] ) { + throw $this->constraint( + 'NOT NULL constraint failed: ' . $table['name'] . '.' . $column['name'], + '23000', + 19, + 'Integrity constraint violation' + ); + } + } + + // CHECK constraints. + $this->check_constraints( $table, $new_row, $new_rowid, $evaluator ); + + // Rowid/UNIQUE constraints (excluding this row). + if ( $new_rowid !== $rowid && isset( $table['rows'][ $new_rowid ] ) ) { + throw $this->constraint( + 'UNIQUE constraint failed: ' . $table['name'] . '.' . ( null !== $rowid_alias ? $table['columns'][ $rowid_alias ]['name'] : 'rowid' ), + '23000', + 19, + 'Integrity constraint violation' + ); + } + $found = $this->find_unique_conflict( $lower, $new_row, $rowid ); + if ( null !== $found ) { + $names = array(); + foreach ( $found[1] as $col ) { + $names[] = $table['name'] . '.' . $col; + } + throw $this->constraint( + 'UNIQUE constraint failed: ' . implode( ', ', $names ), + '23000', + 19, + 'Integrity constraint violation' + ); + } + + // Apply the update. + if ( $new_rowid !== $rowid ) { + unset( $this->db['tables'][ $lower ]['rows'][ $rowid ] ); + $this->db['tables'][ $lower ]['rows'][ $new_rowid ] = $new_row; + } else { + $this->db['tables'][ $lower ]['rows'][ $rowid ] = $new_row; + } + + // Foreign keys: child side for the new values, parent side for changes. + try { + $this->check_foreign_keys_child( $lower, $new_row ); + $this->enforce_foreign_keys_parent_update( $lower, $old_row, $new_row, $evaluator ); + } catch ( Exception $e ) { + if ( $new_rowid !== $rowid ) { + unset( $this->db['tables'][ $lower ]['rows'][ $new_rowid ] ); + $this->db['tables'][ $lower ]['rows'][ $rowid ] = $old_row; + } else { + $this->db['tables'][ $lower ]['rows'][ $rowid ] = $old_row; + } + throw $e; + } + + // Fire AFTER UPDATE triggers. + $this->fire_triggers( $lower, 'UPDATE', $old_row, $new_row, $new_rowid, $set_columns ); + } + + /** + * Insert rows into the sqlite_sequence virtual table. + * + * @param array $statement The insert statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + private function insert_sqlite_sequence( $statement, $evaluator ) { + $src = $statement['src']; + if ( 'default_values' === $src['t'] ) { + $source_rows = array( array( null, null ) ); + } else { + if ( ! empty( $statement['with'] ) ) { + $src['with'] = array_merge( $statement['with'], isset( $src['with'] ) ? $src['with'] : array() ); + } + $result = $evaluator->select( $src ); + $source_rows = $result['rows']; + } + + $targets = null !== $statement['cols'] ? array_map( 'strtolower', $statement['cols'] ) : array( 'name', 'seq' ); + foreach ( $targets as $target ) { + if ( ! in_array( $target, array( 'name', 'seq' ), true ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'table sqlite_sequence has no column named ' . $target ); + } + } + + $changes = 0; + foreach ( $source_rows as $source_row ) { + if ( count( $source_row ) !== count( $targets ) ) { + throw new WP_PHP_Engine_SQL_Exception( + sprintf( 'table sqlite_sequence has %d columns but %d values were supplied', count( $targets ), count( $source_row ) ) + ); + } + $row = array( + 'name' => null, + 'seq' => null, + ); + foreach ( $targets as $position => $target ) { + $row[ $target ] = $source_row[ $position ]; + } + if ( null !== $row['name'] ) { + $this->db['sequences'][ $this->sequence_key( (string) $row['name'], isset( $statement['db'] ) ? $statement['db'] : null ) ] = (int) $row['seq']; + $changes += 1; + } + } + + $this->changes = $changes; + $this->total_changes += $changes; + $result = $this->empty_result(); + $result['changes'] = $changes; + return $result; + } + + /** + * Update rows in the sqlite_sequence virtual table. + * + * @param array $statement The update statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + private function update_sqlite_sequence( $statement, $evaluator ) { + $virtual = $this->virtual_table_result( 'sqlite_sequence', isset( $statement['db'] ) ? $statement['db'] : null ); + $changes = 0; + + foreach ( $virtual['rows'] as $row ) { + $current = array( + 'name' => $row[0], + 'seq' => $row[1], + ); + $frame = array( $this->make_sqlite_sequence_slot( $current ) ); + if ( null !== $statement['where'] && ! WP_PHP_Engine_Values::is_truthy( $evaluator->eval( $statement['where'], $frame ) ) ) { + continue; + } + + $new_row = $current; + foreach ( $statement['set'] as $assignment ) { + if ( isset( $assignment['cols'] ) ) { + $values = $evaluator->eval_row_subquery( $assignment['e'], $frame ); + foreach ( $assignment['cols'] as $position => $col ) { + $col_lower = strtolower( $col ); + if ( ! array_key_exists( $col_lower, $new_row ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such column: ' . $col ); + } + $new_row[ $col_lower ] = null !== $values && array_key_exists( $position, $values ) ? $values[ $position ] : null; + } + continue; + } + $col_lower = strtolower( $assignment['col'] ); + if ( ! array_key_exists( $col_lower, $new_row ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such column: ' . $assignment['col'] ); + } + $new_row[ $col_lower ] = $evaluator->eval( $assignment['e'], $frame ); + } + + unset( $this->db['sequences'][ $this->sequence_key( (string) $current['name'], isset( $statement['db'] ) ? $statement['db'] : null ) ] ); + if ( null !== $new_row['name'] ) { + $this->db['sequences'][ $this->sequence_key( (string) $new_row['name'], isset( $statement['db'] ) ? $statement['db'] : null ) ] = (int) $new_row['seq']; + } + $changes += 1; + } + + $this->changes = $changes; + $this->total_changes += $changes; + $result = $this->empty_result(); + $result['changes'] = $changes; + return $result; + } + + /** + * Build an evaluator slot for a sqlite_sequence row. + * + * @param array $row The sqlite_sequence row keyed by column name. + * @return array The evaluator slot. + */ + private function make_sqlite_sequence_slot( $row ) { + return array( + 'alias' => 'sqlite_sequence', + 'cols' => array( + 'name' => $row['name'], + 'seq' => $row['seq'], + ), + 'names' => array( + 'name' => 'name', + 'seq' => 'seq', + ), + 'aff' => array(), + 'coll' => array(), + 'decl' => array(), + 'rowid' => null, + ); + } + + /* + * ---------------------------------------------------------------------- + * DELETE. + * ---------------------------------------------------------------------- + */ + + /** + * Execute a DELETE statement. + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + private function execute_delete( $statement, $evaluator ) { + $engine = $this; + return $this->atomic( + function () use ( $statement, $evaluator, $engine ) { + return $engine->do_delete( $statement, $evaluator ); + } + ); + } + + /** + * The DELETE implementation. + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + public function do_delete( $statement, $evaluator ) { + $lower = strtolower( $statement['tbl'] ); + + // DELETE FROM sqlite_sequence resets AUTOINCREMENT counters. + if ( 'sqlite_sequence' === $lower && $this->sequence_table_exists( isset( $statement['db'] ) ? $statement['db'] : null ) ) { + $virtual = $this->virtual_table_result( 'sqlite_sequence', isset( $statement['db'] ) ? $statement['db'] : null ); + $changes = 0; + foreach ( $virtual['rows'] as $row ) { + $frame = array( + array( + 'alias' => 'sqlite_sequence', + 'cols' => array( + 'name' => $row[0], + 'seq' => $row[1], + ), + 'names' => array( + 'name' => 'name', + 'seq' => 'seq', + ), + 'aff' => array(), + 'coll' => array(), + 'decl' => array(), + 'rowid' => null, + ), + ); + if ( null === $statement['where'] || WP_PHP_Engine_Values::is_truthy( $evaluator->eval( $statement['where'], $frame ) ) ) { + $this->delete_sequences( $row[0], isset( $statement['db'] ) ? $statement['db'] : null ); + $changes += 1; + } + } + $this->changes = $changes; + $result = $this->empty_result(); + $result['changes'] = $changes; + return $result; + } + + $lower = $this->resolve_table_key( $lower ); + if ( null === $lower ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['tbl'] ); + } + $table = $this->db['tables'][ $lower ]; + $alias = null !== $statement['alias'] ? strtolower( $statement['alias'] ) : self::bare_table_name( $lower ); + + if ( ! empty( $statement['with'] ) ) { + foreach ( $statement['with'] as $name => $cte ) { + $evaluator->ctes[ $name ] = $cte; + } + } + + $matching = array(); + $scan_rows = $table['rows']; + ksort( $scan_rows ); + foreach ( $scan_rows as $rowid => $row ) { + $frame = array( $this->make_table_slot( $table, $row, $rowid, $alias ) ); + if ( null === $statement['where'] || WP_PHP_Engine_Values::is_truthy( $evaluator->eval( $statement['where'], $frame ) ) ) { + $matching[] = $rowid; + } + } + + $changes = 0; + foreach ( $matching as $rowid ) { + if ( ! isset( $this->db['tables'][ $lower ]['rows'][ $rowid ] ) ) { + continue; + } + $this->delete_row( $lower, $rowid, $evaluator, true ); + $changes += 1; + } + + $this->changes = $changes; + $this->total_changes += $changes; + $result = $this->empty_result(); + $result['changes'] = $changes; + return $result; + } + + /** + * Delete a single row, enforcing foreign keys and firing triggers. + * + * @param string $lower The lowercase table name. + * @param int $rowid The rowid. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @param bool $fire_triggers Whether to fire triggers. + */ + private function delete_row( $lower, $rowid, $evaluator, $fire_triggers ) { + $table = $this->db['tables'][ $lower ]; + $old_row = $table['rows'][ $rowid ]; + + unset( $this->db['tables'][ $lower ]['rows'][ $rowid ] ); + + // Foreign keys: parent side. + $this->enforce_foreign_keys_parent_delete( $lower, $old_row, $evaluator ); + + if ( $fire_triggers ) { + $this->fire_triggers( $lower, 'DELETE', $old_row, null, $rowid, null ); + } + } + + /* + * ---------------------------------------------------------------------- + * Row helpers: affinity, constraints, uniqueness. + * ---------------------------------------------------------------------- + */ + + /** + * Apply column affinities and STRICT type checks to a row. + * + * @param array $table The table definition. + * @param array $row The row (lowercase col => value). + * @return array The coerced row. + */ + private function apply_row_affinity( $table, $row ) { + foreach ( $table['columns'] as $col_lower => $column ) { + if ( ! array_key_exists( $col_lower, $row ) ) { + $row[ $col_lower ] = null; + continue; + } + $value = $row[ $col_lower ]; + if ( null === $value ) { + continue; + } + $value = WP_PHP_Engine_Values::apply_affinity( $value, $column['affinity'] ); + + if ( $table['strict'] ) { + $value = $this->check_strict_type( $table, $column, $value ); + } + $row[ $col_lower ] = $value; + } + return $row; + } + + /** + * Enforce STRICT table typing for a value. + * + * @param array $table The table definition. + * @param array $column The column definition. + * @param mixed $value The value (after affinity). + * @return mixed The checked value. + */ + private function check_strict_type( $table, $column, $value ) { + $declared = strtoupper( (string) $column['type'] ); + $base = preg_replace( '/\s*\(.*$/', '', $declared ); + switch ( $base ) { + case 'INT': + case 'INTEGER': + if ( is_int( $value ) ) { + return $value; + } + if ( is_float( $value ) && (float) (int) $value === $value ) { + return (int) $value; + } + break; + case 'REAL': + if ( is_float( $value ) ) { + return $value; + } + if ( is_int( $value ) ) { + return (float) $value; + } + break; + case 'TEXT': + if ( is_string( $value ) ) { + return $value; + } + break; + case 'BLOB': + return $value; + case 'ANY': + return $value; + default: + return $value; + } + throw $this->constraint( + sprintf( + 'cannot store %s value in %s column %s.%s', + strtoupper( WP_PHP_Engine_Values::type_of( $value ) ), + $base, + $table['name'], + $column['name'] + ), + '23000', + 19, + 'Integrity constraint violation' + ); + } + + /** + * Evaluate CHECK constraints for a row. + * + * @param array $table The table definition. + * @param array $row The row. + * @param int|null $rowid The rowid. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + */ + private function check_constraints( $table, $row, $rowid, $evaluator ) { + if ( 0 === count( $table['checks'] ) ) { + return; + } + $frame = array( $this->make_table_slot( $table, $row, $rowid, strtolower( $table['name'] ) ) ); + foreach ( $table['checks'] as $check ) { + $value = $evaluator->eval( $check['e'], $frame ); + if ( null !== $value && ! WP_PHP_Engine_Values::is_truthy( $value ) ) { + throw $this->constraint( + 'CHECK constraint failed: ' . ( null !== $check['name'] ? $check['name'] : $table['name'] ), + '23000', + 19, + 'Integrity constraint violation' + ); + } + } + } + + /** + * Find a UNIQUE constraint conflict for a candidate row. + * + * @param string $lower The lowercase table name. + * @param array $candidate The candidate row. + * @param int|null $exclude_rowid A rowid to exclude (for UPDATE). + * @return array|null The conflicting array( rowid, column names ), or null. + */ + private function find_unique_conflict( $lower, $candidate, $exclude_rowid ) { + $table = $this->db['tables'][ $lower ]; + // SQLite checks the most recently created index first. + foreach ( array_reverse( $this->db['indexes'] ) as $index ) { + if ( $index['tbl'] !== $lower || ! $index['unique'] ) { + continue; + } + // Collect candidate key values; NULLs never conflict. + $key = array(); + $has_null = false; + foreach ( $index['cols'] as $index_col ) { + $col_lower = strtolower( $index_col['name'] ); + $value = isset( $candidate[ $col_lower ] ) ? $candidate[ $col_lower ] : null; + if ( null === $value ) { + $has_null = true; + break; + } + $key[ $col_lower ] = $value; + } + if ( $has_null ) { + continue; + } + foreach ( $table['rows'] as $rowid => $row ) { + if ( null !== $exclude_rowid && $rowid === $exclude_rowid ) { + continue; + } + $match = true; + foreach ( $index['cols'] as $index_col ) { + $col_lower = strtolower( $index_col['name'] ); + $collation = null !== $index_col['collate'] + ? $index_col['collate'] + : ( isset( $table['columns'][ $col_lower ]['collate'] ) && null !== $table['columns'][ $col_lower ]['collate'] + ? $table['columns'][ $col_lower ]['collate'] + : 'BINARY' ); + $existing = isset( $row[ $col_lower ] ) ? $row[ $col_lower ] : null; + if ( null === $existing || 0 !== WP_PHP_Engine_Values::compare( $key[ $col_lower ], $existing, $collation ) ) { + $match = false; + break; + } + } + if ( $match ) { + $names = array(); + foreach ( $index['cols'] as $index_col ) { + $col_lower = strtolower( $index_col['name'] ); + $names[] = isset( $table['columns'][ $col_lower ] ) ? $table['columns'][ $col_lower ]['name'] : $index_col['name']; + } + return array( $rowid, $names ); + } + } + } + return null; + } + + /** + * Build a scope slot for a table row. + * + * @param array $table The table definition. + * @param array $row The row values. + * @param int|null $rowid The rowid. + * @param string $alias The lowercase alias. + * @return array The slot. + */ + public function make_table_slot( $table, $row, $rowid, $alias ) { + $names = array(); + $aff = array(); + $coll = array(); + $decl = array(); + $srct = array(); + foreach ( $table['columns'] as $col_lower => $column ) { + $names[ $col_lower ] = $column['name']; + $aff[ $col_lower ] = $column['affinity']; + $coll[ $col_lower ] = null !== $column['collate'] ? $column['collate'] : 'BINARY'; + $decl[ $col_lower ] = $column['type']; + $srct[ $col_lower ] = $table['name']; + } + return array( + 'alias' => $alias, + 'cols' => $row, + 'names' => $names, + 'aff' => $aff, + 'coll' => $coll, + 'decl' => $decl, + 'srct' => $srct, + 'rowid' => $rowid, + 'has_rowid' => true, + ); + } + + /** + * Create a constraint exception. + * + * @param string $message The message. + * @param string $sqlstate The SQLSTATE code. + * @param int $code The SQLite error code. + * @param string $category The PDO error category text. + * @return WP_PHP_Engine_Constraint_Exception + */ + private function constraint( $message, $sqlstate = 'HY000', $code = 1, $category = 'General error' ) { + return new WP_PHP_Engine_Constraint_Exception( + new WP_PHP_Engine_SQL_Exception( $message, $sqlstate, $code, $category ) + ); + } + + /* + * ---------------------------------------------------------------------- + * Triggers. + * ---------------------------------------------------------------------- + */ + + /** + * Fire AFTER triggers for a row operation. + * + * @param string $lower The lowercase table name. + * @param string $event INSERT, UPDATE, or DELETE. + * @param array|null $old_row The old row (UPDATE/DELETE). + * @param array|null $new_row The new row (INSERT/UPDATE). + * @param int $rowid The rowid. + * @param array|null $set_columns The assigned columns (UPDATE only). + */ + private function fire_triggers( $lower, $event, $old_row, $new_row, $rowid, $set_columns ) { + if ( $this->trigger_depth > 0 ) { + return; // Recursive triggers are disabled. + } + $bare = self::bare_table_name( $lower ); + foreach ( $this->db['triggers'] as $trigger ) { + if ( strtolower( $trigger['tbl'] ) !== $bare || $trigger['event'] !== $event ) { + continue; + } + if ( 'UPDATE' === $event && null !== $trigger['of_cols'] && null !== $set_columns ) { + $intersects = false; + foreach ( $trigger['of_cols'] as $col ) { + if ( in_array( strtolower( $col ), $set_columns, true ) ) { + $intersects = true; + break; + } + } + if ( ! $intersects ) { + continue; + } + } + + $table = $this->db['tables'][ $lower ]; + $frame = array(); + if ( null !== $new_row ) { + $frame[] = $this->make_table_slot( $table, $new_row, $rowid, 'new' ); + } + if ( null !== $old_row ) { + $frame[] = $this->make_table_slot( $table, $old_row, $rowid, 'old' ); + } + + $this->trigger_depth += 1; + $saved_scopes = $this->extra_scopes; + $this->extra_scopes[] = $frame; + $saved_changes = $this->changes; + try { + if ( null !== $trigger['when'] ) { + $evaluator = new WP_PHP_Engine_Evaluator( $this, array() ); + $evaluator->scopes = $this->extra_scopes; + if ( ! WP_PHP_Engine_Values::is_truthy( $evaluator->eval( $trigger['when'], $frame ) ) ) { + continue; + } + } + foreach ( $trigger['body'] as $body_statement ) { + $this->execute_statement( $body_statement, array() ); + } + } catch ( WP_PHP_Engine_Raise_Ignore_Exception $e ) { + // RAISE(IGNORE) skips the remainder of the trigger. + } finally { + $this->extra_scopes = $saved_scopes; + $this->trigger_depth -= 1; + $this->changes = $saved_changes; + } + } + } + + /* + * ---------------------------------------------------------------------- + * Foreign keys. + * ---------------------------------------------------------------------- + */ + + /** + * Check child-side foreign keys for a row. + * + * @param string $lower The lowercase table name. + * @param array $row The row. + */ + private function check_foreign_keys_child( $lower, $row ) { + if ( ! $this->foreign_keys_enabled ) { + return; + } + $table = $this->db['tables'][ $lower ]; + foreach ( $table['fks'] as $fk ) { + if ( ! $this->foreign_key_satisfied( $fk, $row ) ) { + throw new WP_PHP_Engine_SQL_Exception( + 'FOREIGN KEY constraint failed', + '23000', + 19, + 'Integrity constraint violation' + ); + } + } + } + + /** + * Check whether a child row satisfies a foreign key. + * + * @param array $fk The foreign key definition. + * @param array $row The child row. + * @return bool Whether the constraint is satisfied. + */ + private function foreign_key_satisfied( $fk, $row ) { + $values = array(); + foreach ( $fk['cols'] as $col ) { + $value = isset( $row[ strtolower( $col ) ] ) ? $row[ strtolower( $col ) ] : null; + if ( null === $value ) { + return true; // NULLs satisfy FK constraints. + } + $values[] = $value; + } + + $parent_lower = strtolower( $fk['ref_table'] ); + $parent = $this->get_table( $parent_lower ); + if ( null === $parent ) { + return false; + } + $ref_cols = $this->foreign_key_parent_columns( $fk, $parent ); + + foreach ( $parent['rows'] as $parent_row ) { + $match = true; + foreach ( $ref_cols as $position => $ref_lower ) { + $parent_value = isset( $parent_row[ $ref_lower ] ) ? $parent_row[ $ref_lower ] : null; + $collation = isset( $parent['columns'][ $ref_lower ]['collate'] ) && null !== $parent['columns'][ $ref_lower ]['collate'] + ? $parent['columns'][ $ref_lower ]['collate'] + : 'BINARY'; + if ( null === $parent_value || 0 !== WP_PHP_Engine_Values::compare( $values[ $position ], $parent_value, $collation ) ) { + $match = false; + break; + } + } + if ( $match ) { + return true; + } + } + return false; + } + + /** + * Get the lowercase parent column names of a foreign key. + * + * @param array $fk The foreign key definition. + * @param array $parent_table The parent table definition. + * @return array The lowercase parent column names. + */ + private function foreign_key_parent_columns( $fk, $parent_table ) { + if ( null !== $fk['ref_cols'] ) { + return array_map( 'strtolower', $fk['ref_cols'] ); + } + if ( null !== $parent_table['rowid_alias'] ) { + return array( $parent_table['rowid_alias'] ); + } + if ( null !== $parent_table['pk_cols'] ) { + return $parent_table['pk_cols']; + } + return array(); + } + + /** + * Enforce parent-side foreign keys when a parent row is deleted. + * + * @param string $parent_lower The lowercase parent table name. + * @param array $old_row The deleted parent row. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + */ + private function enforce_foreign_keys_parent_delete( $parent_lower, $old_row, $evaluator ) { + if ( ! $this->foreign_keys_enabled ) { + return; + } + $parent = $this->db['tables'][ $parent_lower ]; + foreach ( $this->db['tables'] as $child_lower => $child ) { + foreach ( $child['fks'] as $fk ) { + if ( $this->resolve_table_key( strtolower( $fk['ref_table'] ) ) !== $parent_lower ) { + continue; + } + $ref_cols = $this->foreign_key_parent_columns( $fk, $parent ); + if ( 0 === count( $ref_cols ) ) { + continue; + } + // Find child rows referencing the deleted parent row. + foreach ( $child['rows'] as $child_rowid => $child_row ) { + if ( ! isset( $this->db['tables'][ $child_lower ]['rows'][ $child_rowid ] ) ) { + continue; + } + $references = true; + foreach ( $fk['cols'] as $position => $col ) { + $child_value = isset( $child_row[ strtolower( $col ) ] ) ? $child_row[ strtolower( $col ) ] : null; + $parent_value = isset( $old_row[ $ref_cols[ $position ] ] ) ? $old_row[ $ref_cols[ $position ] ] : null; + if ( null === $child_value || null === $parent_value + || 0 !== WP_PHP_Engine_Values::compare( $child_value, $parent_value ) ) { + $references = false; + break; + } + } + if ( ! $references ) { + continue; + } + // Another parent row with the same key still satisfies the FK. + if ( $this->foreign_key_satisfied( $fk, $child_row ) ) { + continue; + } + switch ( $fk['on_delete'] ) { + case 'CASCADE': + $this->delete_row( $child_lower, $child_rowid, $evaluator, true ); + break; + case 'SET NULL': + case 'SET DEFAULT': + $new_child = $this->db['tables'][ $child_lower ]['rows'][ $child_rowid ]; + foreach ( $fk['cols'] as $col ) { + $col_lower = strtolower( $col ); + if ( 'SET DEFAULT' === $fk['on_update'] || 'SET DEFAULT' === $fk['on_delete'] ) { + $column = $child['columns'][ $col_lower ]; + $new_child[ $col_lower ] = $column['has_default'] && null !== $column['default'] + ? $evaluator->eval( $column['default'], array() ) + : null; + } else { + $new_child[ $col_lower ] = null; + } + } + $this->update_row( $child_lower, $child_rowid, $new_child, $evaluator, array_map( 'strtolower', $fk['cols'] ) ); + break; + default: + throw new WP_PHP_Engine_SQL_Exception( + 'FOREIGN KEY constraint failed', + '23000', + 19, + 'Integrity constraint violation' + ); + } + } + } + } + } + + /** + * Enforce parent-side foreign keys when a parent row is updated. + * + * @param string $parent_lower The lowercase parent table name. + * @param array $old_row The old parent row. + * @param array $new_row The new parent row. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + */ + private function enforce_foreign_keys_parent_update( $parent_lower, $old_row, $new_row, $evaluator ) { + if ( ! $this->foreign_keys_enabled ) { + return; + } + $parent = $this->db['tables'][ $parent_lower ]; + foreach ( $this->db['tables'] as $child_lower => $child ) { + foreach ( $child['fks'] as $fk ) { + if ( $this->resolve_table_key( strtolower( $fk['ref_table'] ) ) !== $parent_lower ) { + continue; + } + $ref_cols = $this->foreign_key_parent_columns( $fk, $parent ); + if ( 0 === count( $ref_cols ) ) { + continue; + } + // Did the referenced key change? + $changed = false; + foreach ( $ref_cols as $ref_lower ) { + $old_value = isset( $old_row[ $ref_lower ] ) ? $old_row[ $ref_lower ] : null; + $new_value = isset( $new_row[ $ref_lower ] ) ? $new_row[ $ref_lower ] : null; + if ( $old_value !== $new_value ) { + $changed = true; + break; + } + } + if ( ! $changed ) { + continue; + } + foreach ( $child['rows'] as $child_rowid => $child_row ) { + $references = true; + foreach ( $fk['cols'] as $position => $col ) { + $child_value = isset( $child_row[ strtolower( $col ) ] ) ? $child_row[ strtolower( $col ) ] : null; + $parent_value = isset( $old_row[ $ref_cols[ $position ] ] ) ? $old_row[ $ref_cols[ $position ] ] : null; + if ( null === $child_value || null === $parent_value + || 0 !== WP_PHP_Engine_Values::compare( $child_value, $parent_value ) ) { + $references = false; + break; + } + } + if ( ! $references || $this->foreign_key_satisfied( $fk, $child_row ) ) { + continue; + } + switch ( $fk['on_update'] ) { + case 'CASCADE': + $new_child = $this->db['tables'][ $child_lower ]['rows'][ $child_rowid ]; + foreach ( $fk['cols'] as $position => $col ) { + $new_child[ strtolower( $col ) ] = isset( $new_row[ $ref_cols[ $position ] ] ) ? $new_row[ $ref_cols[ $position ] ] : null; + } + $this->update_row( $child_lower, $child_rowid, $new_child, $evaluator, array_map( 'strtolower', $fk['cols'] ) ); + break; + case 'SET NULL': + case 'SET DEFAULT': + $new_child = $this->db['tables'][ $child_lower ]['rows'][ $child_rowid ]; + foreach ( $fk['cols'] as $col ) { + $col_lower = strtolower( $col ); + if ( 'SET DEFAULT' === $fk['on_update'] || 'SET DEFAULT' === $fk['on_delete'] ) { + $column = $child['columns'][ $col_lower ]; + $new_child[ $col_lower ] = $column['has_default'] && null !== $column['default'] + ? $evaluator->eval( $column['default'], array() ) + : null; + } else { + $new_child[ $col_lower ] = null; + } + } + $this->update_row( $child_lower, $child_rowid, $new_child, $evaluator, array_map( 'strtolower', $fk['cols'] ) ); + break; + default: + throw new WP_PHP_Engine_SQL_Exception( + 'FOREIGN KEY constraint failed', + '23000', + 19, + 'Integrity constraint violation' + ); + } + } + } + } + } + + /* + * ---------------------------------------------------------------------- + * DDL. + * ---------------------------------------------------------------------- + */ + + /** + * Execute a CREATE TABLE statement. + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + private function execute_create_table( $statement, $evaluator ) { + $lower = strtolower( $statement['name'] ); + $key = $statement['temp'] ? 'temp.' . $lower : $lower; + if ( isset( $this->db['tables'][ $key ] ) + || ( ! $statement['temp'] && isset( $this->db['views'][ $lower ] ) ) ) { + if ( $statement['if_not_exists'] ) { + return $this->empty_result(); + } + throw new WP_PHP_Engine_SQL_Exception( 'table ' . $statement['name'] . ' already exists' ); + } + + $table = $this->build_table_meta( $statement ); + + $this->db['tables'][ $key ] = $table; + $lower = $key; + if ( $table['autoincrement'] ) { + if ( $table['temp'] ) { + $this->db['has_temp_sequence_table'] = true; + } else { + $this->db['has_sequence_table'] = true; + } + } + + // Create implicit indexes for PRIMARY KEY and UNIQUE constraints, + // numbered in declaration order (column constraints come first). + $autoindex = 0; + foreach ( $statement['columns'] as $column ) { + if ( $column['pk'] && null === $table['rowid_alias'] ) { + $autoindex += 1; + $this->add_autoindex( + $lower, + array( + array( + 'name' => $column['name'], + 'collate' => null, + 'dir' => 'ASC', + ), + ), + 'pk', + $autoindex + ); + } + if ( $column['unique'] ) { + $autoindex += 1; + $this->add_autoindex( + $lower, + array( + array( + 'name' => $column['name'], + 'collate' => null, + 'dir' => 'ASC', + ), + ), + 'u', + $autoindex + ); + } + } + foreach ( $statement['constraints'] as $constraint ) { + if ( 'pk' === $constraint['kind'] && null === $table['rowid_alias'] ) { + $autoindex += 1; + $this->add_autoindex( $lower, $constraint['cols'], 'pk', $autoindex ); + } elseif ( 'unique' === $constraint['kind'] ) { + $autoindex += 1; + $this->add_autoindex( $lower, $constraint['cols'], 'u', $autoindex ); + } + } + + return $this->empty_result(); + } + + /** + * Build the table metadata from a CREATE TABLE statement. + * + * @param array $statement The statement AST node. + * @return array The table definition. + */ + private function build_table_meta( $statement ) { + $columns = array(); + foreach ( $statement['columns'] as $column ) { + $col_lower = strtolower( $column['name'] ); + if ( isset( $columns[ $col_lower ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'duplicate column name: ' . $column['name'] ); + } + $columns[ $col_lower ] = array( + 'name' => $column['name'], + 'type' => $column['type'], + 'affinity' => WP_PHP_Engine_Values::affinity_for_type( $column['type'] ), + 'collate' => $column['collate'], + 'notnull' => $column['notnull'], + 'default' => $column['default'], + 'default_text' => isset( $column['default_text'] ) ? $column['default_text'] : null, + 'has_default' => $column['has_default'], + ); + } + + // Resolve the PRIMARY KEY. + $pk_cols = null; + $pk_constraint = null; + $pk_column = null; + foreach ( $statement['constraints'] as $constraint ) { + if ( 'pk' === $constraint['kind'] ) { + $pk_constraint = $constraint; + $pk_cols = array(); + foreach ( $constraint['cols'] as $col ) { + $pk_cols[] = strtolower( $col['name'] ); + } + } + } + foreach ( $statement['columns'] as $column ) { + if ( $column['pk'] ) { + if ( null !== $pk_cols ) { + throw new WP_PHP_Engine_SQL_Exception( + 'table "' . $statement['name'] . '" has more than one primary key' + ); + } + $pk_column = $column; + $pk_cols = array( strtolower( $column['name'] ) ); + } + } + + // PRIMARY KEY columns are implicitly NOT NULL in rowid tables... + // except that SQLite, for backwards compatibility, allows NULLs in + // non-INTEGER primary keys. We follow the strict/WITHOUT ROWID rule. + $rowid_alias = null; + if ( null !== $pk_column + && ! $statement['without_rowid'] + && null !== $pk_column['type'] + && 'INTEGER' === strtoupper( $pk_column['type'] ) + && ! $pk_column['pk_desc'] ) { + $rowid_alias = strtolower( $pk_column['name'] ); + } + + $autoincrement = null !== $pk_column && $pk_column['autoincrement']; + + // Collect CHECK constraints. + $checks = array(); + foreach ( $statement['constraints'] as $constraint ) { + if ( 'check' === $constraint['kind'] ) { + $checks[] = array( + 'name' => $constraint['name'], + 'e' => $constraint['e'], + ); + } + } + foreach ( $statement['columns'] as $column ) { + foreach ( $column['checks'] as $check ) { + $checks[] = $check; + } + } + + // Collect FOREIGN KEY constraints. + $fks = array(); + foreach ( $statement['constraints'] as $constraint ) { + if ( 'fk' === $constraint['kind'] ) { + $fks[] = $constraint['fk']; + } + } + foreach ( $statement['columns'] as $column ) { + if ( null !== $column['fk'] ) { + $fks[] = $column['fk']; + } + } + + return array( + 'name' => $statement['name'], + 'temp' => $statement['temp'], + 'columns' => $columns, + 'rowid_alias' => $rowid_alias, + 'autoincrement' => $autoincrement, + 'strict' => $statement['strict'], + 'without_rowid' => $statement['without_rowid'], + 'pk_cols' => $pk_cols, + 'pk_constraint' => null !== $pk_constraint, + 'checks' => $checks, + 'fks' => $fks, + 'rows' => array(), + 'sql' => $statement['sql'], + ); + } + + /** + * Add an implicit (auto) index for a PRIMARY KEY or UNIQUE constraint. + * + * @param string $lower The lowercase table name. + * @param array $cols The indexed columns. + * @param string $origin The origin: 'pk' or 'u'. + * @param int $number The autoindex ordinal. + */ + private function add_autoindex( $lower, $cols, $origin, $number ) { + $name = 'sqlite_autoindex_' . $this->db['tables'][ $lower ]['name'] . '_' . $number; + $prefix = 0 === strpos( $lower, 'temp.' ) ? 'temp.' : ''; + $this->db['indexes'][ $prefix . strtolower( $name ) ] = array( + 'name' => $name, + 'tbl' => $lower, + 'unique' => true, + 'cols' => $cols, + 'origin' => $origin, + 'sql' => null, + ); + } + + /** + * Execute a CREATE TABLE ... AS SELECT statement. + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + private function execute_create_table_as( $statement, $evaluator ) { + $lower = strtolower( $statement['name'] ); + if ( isset( $this->db['tables'][ $lower ] ) ) { + if ( $statement['if_not_exists'] ) { + return $this->empty_result(); + } + throw new WP_PHP_Engine_SQL_Exception( 'table ' . $statement['name'] . ' already exists' ); + } + $result = $evaluator->select( $statement['sel'] ); + $columns = array(); + foreach ( $result['cols'] as $index => $name ) { + $columns[ strtolower( $name ) ] = array( + 'name' => $name, + 'type' => isset( $result['decl'][ $index ] ) ? $result['decl'][ $index ] : null, + 'affinity' => WP_PHP_Engine_Values::affinity_for_type( isset( $result['decl'][ $index ] ) ? $result['decl'][ $index ] : null ), + 'collate' => null, + 'notnull' => false, + 'default' => null, + 'has_default' => false, + ); + } + $rows = array(); + $next = 1; + $lower_names = array_keys( $columns ); + foreach ( $result['rows'] as $row ) { + $assoc = array(); + foreach ( $lower_names as $position => $col_lower ) { + $assoc[ $col_lower ] = isset( $row[ $position ] ) ? $row[ $position ] : null; + } + $rows[ $next ] = $assoc; + $next += 1; + } + $this->db['tables'][ $lower ] = array( + 'name' => $statement['name'], + 'temp' => $statement['temp'], + 'columns' => $columns, + 'rowid_alias' => null, + 'autoincrement' => false, + 'strict' => false, + 'without_rowid' => false, + 'pk_cols' => null, + 'pk_constraint' => false, + 'checks' => array(), + 'fks' => array(), + 'rows' => $rows, + 'sql' => $statement['sql'], + ); + return $this->empty_result(); + } + + /** + * Execute a CREATE INDEX statement. + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + private function execute_create_index( $statement, $evaluator ) { + $table_key = $this->resolve_table_key( strtolower( $statement['tbl'] ) ); + if ( null === $table_key ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: main.' . $statement['tbl'] ); + } + $prefix = 0 === strpos( $table_key, 'temp.' ) ? 'temp.' : ''; + $lower = $prefix . strtolower( $statement['name'] ); + if ( isset( $this->db['indexes'][ $lower ] ) ) { + if ( $statement['if_not_exists'] ) { + return $this->empty_result(); + } + throw new WP_PHP_Engine_SQL_Exception( 'index ' . $statement['name'] . ' already exists' ); + } + $table = $this->db['tables'][ $table_key ]; + $table_lower = $table_key; + foreach ( $statement['cols'] as $col ) { + if ( ! isset( $table['columns'][ strtolower( $col['name'] ) ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such column: ' . $col['name'] ); + } + } + + $index = array( + 'name' => $statement['name'], + 'tbl' => $table_lower, + 'unique' => $statement['unique'], + 'cols' => $statement['cols'], + 'origin' => 'c', + 'sql' => $statement['sql'], + ); + + // Check existing rows for uniqueness violations. + if ( $statement['unique'] ) { + $seen = array(); + foreach ( $table['rows'] as $row ) { + $key_parts = array(); + $has_null = false; + foreach ( $statement['cols'] as $col ) { + $col_lower = strtolower( $col['name'] ); + $value = isset( $row[ $col_lower ] ) ? $row[ $col_lower ] : null; + if ( null === $value ) { + $has_null = true; + break; + } + $collation = null !== $col['collate'] + ? $col['collate'] + : ( null !== $table['columns'][ $col_lower ]['collate'] ? $table['columns'][ $col_lower ]['collate'] : 'BINARY' ); + if ( 'NOCASE' === $collation && is_string( $value ) ) { + $key_parts[] = 't:' . strtolower( $value ); + } else { + $key_parts[] = WP_PHP_Engine_Evaluator::value_key( $value ); + } + } + if ( $has_null ) { + continue; + } + $key = implode( '|', $key_parts ); + if ( isset( $seen[ $key ] ) ) { + $names = array(); + foreach ( $statement['cols'] as $col ) { + $names[] = $table['name'] . '.' . $table['columns'][ strtolower( $col['name'] ) ]['name']; + } + throw new WP_PHP_Engine_SQL_Exception( + 'UNIQUE constraint failed: ' . implode( ', ', $names ), + '23000', + 19, + 'Integrity constraint violation' + ); + } + $seen[ $key ] = true; + } + } + + $this->db['indexes'][ $lower ] = $index; + return $this->empty_result(); + } + + /** + * Execute a CREATE TRIGGER statement. + * + * @param array $statement The statement AST node. + * @return array The result. + */ + private function execute_create_trigger( $statement ) { + $lower = strtolower( $statement['name'] ); + if ( isset( $this->db['triggers'][ $lower ] ) ) { + if ( $statement['if_not_exists'] ) { + return $this->empty_result(); + } + throw new WP_PHP_Engine_SQL_Exception( 'trigger ' . $statement['name'] . ' already exists' ); + } + if ( null === $this->get_table( strtolower( $statement['tbl'] ) ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['tbl'] ); + } + $this->db['triggers'][ $lower ] = array( + 'name' => $statement['name'], + 'tbl' => $statement['tbl'], + 'timing' => $statement['timing'], + 'event' => $statement['event'], + 'of_cols' => $statement['of_cols'], + 'when' => $statement['when'], + 'body' => $statement['body'], + 'temp' => $statement['temp'], + 'sql' => $statement['sql'], + ); + return $this->empty_result(); + } + + /** + * Execute a CREATE VIEW statement. + * + * @param array $statement The statement AST node. + * @return array The result. + */ + private function execute_create_view( $statement ) { + $lower = strtolower( $statement['name'] ); + if ( isset( $this->db['views'][ $lower ] ) || isset( $this->db['tables'][ $lower ] ) ) { + if ( $statement['if_not_exists'] ) { + return $this->empty_result(); + } + throw new WP_PHP_Engine_SQL_Exception( 'table ' . $statement['name'] . ' already exists' ); + } + $this->db['views'][ $lower ] = array( + 'name' => $statement['name'], + 'sel' => $statement['sel'], + 'sql' => $statement['sql'], + ); + return $this->empty_result(); + } + + /** + * Execute a DROP statement. + * + * @param array $statement The statement AST node. + * @return array The result. + */ + private function execute_drop( $statement ) { + $lower = strtolower( $statement['name'] ); + switch ( $statement['what'] ) { + case 'table': + $key = $this->resolve_table_key( $lower ); + if ( null === $key ) { + if ( $statement['if_exists'] ) { + return $this->empty_result(); + } + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['name'] ); + } + $table = $this->db['tables'][ $key ]; + unset( $this->db['tables'][ $key ] ); + unset( $this->db['sequences'][ $this->sequence_key_for_table( $table ) ] ); + foreach ( $this->db['indexes'] as $index_lower => $index ) { + if ( $index['tbl'] === $key ) { + unset( $this->db['indexes'][ $index_lower ] ); + } + } + foreach ( $this->db['triggers'] as $trigger_lower => $trigger ) { + if ( strtolower( $trigger['tbl'] ) === self::bare_table_name( $key ) ) { + unset( $this->db['triggers'][ $trigger_lower ] ); + } + } + break; + case 'index': + $key = isset( $this->db['indexes'][ 'temp.' . $lower ] ) ? 'temp.' . $lower : $lower; + if ( ! isset( $this->db['indexes'][ $key ] ) || 'c' !== $this->db['indexes'][ $key ]['origin'] ) { + if ( $statement['if_exists'] ) { + return $this->empty_result(); + } + throw new WP_PHP_Engine_SQL_Exception( 'no such index: ' . $statement['name'] ); + } + unset( $this->db['indexes'][ $key ] ); + break; + case 'trigger': + if ( ! isset( $this->db['triggers'][ $lower ] ) ) { + if ( $statement['if_exists'] ) { + return $this->empty_result(); + } + throw new WP_PHP_Engine_SQL_Exception( 'no such trigger: ' . $statement['name'] ); + } + unset( $this->db['triggers'][ $lower ] ); + break; + case 'view': + if ( ! isset( $this->db['views'][ $lower ] ) ) { + if ( $statement['if_exists'] ) { + return $this->empty_result(); + } + throw new WP_PHP_Engine_SQL_Exception( 'no such view: ' . $statement['name'] ); + } + unset( $this->db['views'][ $lower ] ); + break; + } + return $this->empty_result(); + } + + /** + * Execute an ALTER TABLE ... RENAME TO statement. + * + * @param array $statement The statement AST node. + * @return array The result. + */ + private function execute_alter_rename_table( $statement ) { + $lower = $this->resolve_table_key( strtolower( $statement['tbl'] ) ); + if ( null === $lower ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['tbl'] ); + } + $table = $this->db['tables'][ $lower ]; + $prefix = 0 === strpos( $lower, 'temp.' ) ? 'temp.' : ''; + $new_lower = $prefix . strtolower( $statement['new'] ); + if ( $new_lower !== $lower && ( isset( $this->db['tables'][ $new_lower ] ) || isset( $this->db['views'][ $new_lower ] ) ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'there is already another table or index with this name: ' . $statement['new'] ); + } + + $old_name = $table['name']; + $table['name'] = $statement['new']; + + // Rewrite the stored CREATE TABLE statement with the new name. + if ( null !== $table['sql'] ) { + $table['sql'] = preg_replace( + '/(CREATE\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?)(`(?:[^`]|``)+`|"(?:[^"]|"")+"|\[[^\]]+\]|[A-Za-z_][A-Za-z0-9_$]*)/i', + '$1' . str_replace( '$', '\\$', '`' . str_replace( '`', '``', $statement['new'] ) . '`' ), + $table['sql'], + 1 + ); + } + + unset( $this->db['tables'][ $lower ] ); + $this->db['tables'][ $new_lower ] = $table; + + // Update references in indexes, triggers, sequences, and foreign keys. + foreach ( $this->db['indexes'] as $index_lower => $index ) { + if ( $index['tbl'] === $lower ) { + $this->db['indexes'][ $index_lower ]['tbl'] = $new_lower; + } + } + foreach ( $this->db['triggers'] as $trigger_lower => $trigger ) { + if ( strtolower( $trigger['tbl'] ) === self::bare_table_name( $lower ) ) { + $this->db['triggers'][ $trigger_lower ]['tbl'] = $statement['new']; + } + } + $old_sequence_key = $this->sequence_key_for_table( array_merge( $table, array( 'name' => $old_name ) ) ); + $new_sequence_key = $this->sequence_key_for_table( $table ); + if ( isset( $this->db['sequences'][ $old_sequence_key ] ) ) { + $this->db['sequences'][ $new_sequence_key ] = $this->db['sequences'][ $old_sequence_key ]; + unset( $this->db['sequences'][ $old_sequence_key ] ); + } + foreach ( $this->db['tables'] as $other_lower => $other ) { + foreach ( $other['fks'] as $fk_index => $fk ) { + if ( strtolower( $fk['ref_table'] ) === self::bare_table_name( $lower ) ) { + $this->db['tables'][ $other_lower ]['fks'][ $fk_index ]['ref_table'] = $statement['new']; + } + } + } + + return $this->empty_result(); + } + + /** + * Execute an ALTER TABLE ... RENAME COLUMN statement. + * + * @param array $statement The statement AST node. + * @return array The result. + */ + private function execute_alter_rename_column( $statement ) { + $lower = $this->resolve_table_key( strtolower( $statement['tbl'] ) ); + if ( null === $lower ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['tbl'] ); + } + $table = $this->db['tables'][ $lower ]; + $old_lower = strtolower( $statement['old'] ); + $new_lower = strtolower( $statement['new'] ); + if ( ! isset( $table['columns'][ $old_lower ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such column: "' . $statement['old'] . '"' ); + } + if ( $new_lower !== $old_lower && isset( $table['columns'][ $new_lower ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'duplicate column name: ' . $statement['new'] ); + } + + // Rebuild the column map preserving order. + $columns = array(); + foreach ( $table['columns'] as $col_lower => $column ) { + if ( $col_lower === $old_lower ) { + $column['name'] = $statement['new']; + $columns[ $new_lower ] = $column; + } else { + $columns[ $col_lower ] = $column; + } + } + $table['columns'] = $columns; + + // Rewrite rows. + $rows = array(); + foreach ( $table['rows'] as $rowid => $row ) { + $new_row = array(); + foreach ( $row as $col_lower => $value ) { + $new_row[ $col_lower === $old_lower ? $new_lower : $col_lower ] = $value; + } + $rows[ $rowid ] = $new_row; + } + $table['rows'] = $rows; + + if ( $table['rowid_alias'] === $old_lower ) { + $table['rowid_alias'] = $new_lower; + } + if ( null !== $table['pk_cols'] ) { + foreach ( $table['pk_cols'] as $position => $pk_lower ) { + if ( $pk_lower === $old_lower ) { + $table['pk_cols'][ $position ] = $new_lower; + } + } + } + + $this->db['tables'][ $lower ] = $table; + + // Update index column references. + foreach ( $this->db['indexes'] as $index_lower => $index ) { + if ( $index['tbl'] !== $lower ) { + continue; + } + foreach ( $index['cols'] as $position => $col ) { + if ( strtolower( $col['name'] ) === $old_lower ) { + $this->db['indexes'][ $index_lower ]['cols'][ $position ]['name'] = $statement['new']; + } + } + } + + return $this->empty_result(); + } + + /** + * Execute an ALTER TABLE ... ADD COLUMN statement. + * + * @param array $statement The statement AST node. + * @param WP_PHP_Engine_Evaluator $evaluator The evaluator. + * @return array The result. + */ + private function execute_alter_add_column( $statement, $evaluator ) { + $lower = $this->resolve_table_key( strtolower( $statement['tbl'] ) ); + if ( null === $lower ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['tbl'] ); + } + $table = $this->db['tables'][ $lower ]; + $column = $statement['col']; + $col_lower = strtolower( $column['name'] ); + if ( isset( $table['columns'][ $col_lower ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'duplicate column name: ' . $column['name'] ); + } + + $table['columns'][ $col_lower ] = array( + 'name' => $column['name'], + 'type' => $column['type'], + 'affinity' => WP_PHP_Engine_Values::affinity_for_type( $column['type'] ), + 'collate' => $column['collate'], + 'notnull' => $column['notnull'], + 'default' => $column['default'], + 'default_text' => isset( $column['default_text'] ) ? $column['default_text'] : null, + 'has_default' => $column['has_default'], + ); + foreach ( $column['checks'] as $check ) { + $table['checks'][] = $check; + } + if ( null !== $column['fk'] ) { + $table['fks'][] = $column['fk']; + } + + // Existing rows get the default value. + $default = $column['has_default'] && null !== $column['default'] + ? $evaluator->eval( $column['default'], array() ) + : null; + $default = WP_PHP_Engine_Values::apply_affinity( $default, $table['columns'][ $col_lower ]['affinity'] ); + foreach ( $table['rows'] as $rowid => $row ) { + $table['rows'][ $rowid ][ $col_lower ] = $default; + } + + // Update the stored CREATE TABLE statement (best effort: append the + // column before the closing parenthesis). + if ( null !== $table['sql'] ) { + $position = strrpos( $table['sql'], ')' ); + if ( false !== $position ) { + $rendered = $this->render_column_definition_sql( $column ); + $table['sql'] = substr( $table['sql'], 0, $position ) . ', ' . $rendered . substr( $table['sql'], $position ); + } + } + + $this->db['tables'][ $lower ] = $table; + return $this->empty_result(); + } + + /** + * Execute an ALTER TABLE ... DROP COLUMN statement. + * + * @param array $statement The statement AST node. + * @return array The result. + */ + private function execute_alter_drop_column( $statement ) { + $lower = $this->resolve_table_key( strtolower( $statement['tbl'] ) ); + if ( null === $lower ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $statement['tbl'] ); + } + $table = $this->db['tables'][ $lower ]; + $col_lower = strtolower( $statement['col'] ); + if ( ! isset( $table['columns'][ $col_lower ] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such column: "' . $statement['col'] . '"' ); + } + if ( $table['rowid_alias'] === $col_lower + || ( null !== $table['pk_cols'] && in_array( $col_lower, $table['pk_cols'], true ) ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'cannot drop PRIMARY KEY column: "' . $statement['col'] . '"' ); + } + foreach ( $this->db['indexes'] as $index ) { + if ( $index['tbl'] !== $lower ) { + continue; + } + foreach ( $index['cols'] as $col ) { + if ( strtolower( $col['name'] ) === $col_lower ) { + throw new WP_PHP_Engine_SQL_Exception( + 'cannot drop column "' . $statement['col'] . '": indexed' + ); + } + } + } + + unset( $table['columns'][ $col_lower ] ); + foreach ( $table['rows'] as $rowid => $row ) { + unset( $table['rows'][ $rowid ][ $col_lower ] ); + } + $this->db['tables'][ $lower ] = $table; + return $this->empty_result(); + } + + /** + * Render a column definition back to SQL (for ALTER TABLE bookkeeping). + * + * @param array $column The parsed column definition. + * @return string The SQL text. + */ + private function render_column_definition_sql( $column ) { + $sql = '`' . str_replace( '`', '``', $column['name'] ) . '`'; + if ( null !== $column['type'] ) { + $sql .= ' ' . $column['type']; + } + if ( null !== $column['collate'] ) { + $sql .= ' COLLATE ' . $column['collate']; + } + if ( $column['notnull'] ) { + $sql .= ' NOT NULL'; + } + if ( $column['has_default'] ) { + $sql .= ' DEFAULT ' . $this->render_expr_sql( $column['default'] ); + } + return $sql; + } + + /** + * Render a simple expression back to SQL text (defaults, etc.). + * + * @param array|null $expr The expression AST node. + * @return string The SQL text. + */ + private function render_expr_sql( $expr ) { + if ( null === $expr ) { + return 'NULL'; + } + switch ( $expr['t'] ) { + case 'lit': + if ( null === $expr['v'] ) { + return 'NULL'; + } + if ( is_int( $expr['v'] ) || is_float( $expr['v'] ) ) { + return WP_PHP_Engine_Values::to_text( $expr['v'] ); + } + if ( $expr['v'] instanceof WP_PHP_Engine_Blob ) { + return "X'" . bin2hex( $expr['v']->bytes ) . "'"; + } + return "'" . str_replace( "'", "''", $expr['v'] ) . "'"; + case 'now': + return $expr['fn']; + case 'un': + return $expr['op'] . $this->render_expr_sql( $expr['e'] ); + case 'col': + return $expr['name']; + case 'fn': + $args = array(); + foreach ( $expr['args'] as $arg ) { + $args[] = $this->render_expr_sql( $arg ); + } + return $expr['name'] . '(' . implode( ', ', $args ) . ')'; + case 'bin': + return $this->render_expr_sql( $expr['l'] ) . ' ' . $expr['op'] . ' ' . $this->render_expr_sql( $expr['r'] ); + } + return ''; + } + + /* + * ---------------------------------------------------------------------- + * PRAGMA, virtual tables, and table-valued functions. + * ---------------------------------------------------------------------- + */ + + /** + * Execute a PRAGMA statement. + * + * @param array $statement The statement AST node. + * @return array The result. + */ + private function execute_pragma( $statement ) { + $name = $statement['name']; + $value = $statement['value']; + $arg = $statement['arg']; + + switch ( $name ) { + case 'foreign_keys': + if ( null !== $value ) { + $this->foreign_keys_enabled = in_array( strtoupper( (string) $value ), array( 'ON', 'TRUE', 'YES', '1' ), true ); + return $this->empty_result(); + } + return $this->pragma_result( 'foreign_keys', array( array( $this->foreign_keys_enabled ? 1 : 0 ) ) ); + + case 'foreign_key_check': + $violations = $this->foreign_key_check( null !== $arg ? strtolower( (string) $arg ) : null ); + return array( + 'cols' => array( 'table', 'rowid', 'parent', 'fkid' ), + 'rows' => $violations, + 'decl' => array( null, null, null, null ), + 'changes' => 0, + ); + + case 'integrity_check': + case 'quick_check': + if ( null !== $arg && null === $this->get_table( strtolower( (string) $arg ) ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'no such table: ' . $arg ); + } + return $this->pragma_result( 'integrity_check', array( array( 'ok' ) ) ); + + case 'journal_mode': + return $this->pragma_result( 'journal_mode', array( array( 'memory' ) ) ); + + case 'encoding': + return $this->pragma_result( 'encoding', array( array( 'UTF-8' ) ) ); + + case 'busy_timeout': + case 'timeout': + if ( null !== $value ) { + $this->pragma_values['busy_timeout'] = (int) $value; + $this->set_busy_timeout( (int) $value / 1000 ); + return $this->pragma_result( 'timeout', array( array( (int) $value ) ) ); + } + return $this->pragma_result( + 'timeout', + array( array( isset( $this->pragma_values['busy_timeout'] ) ? $this->pragma_values['busy_timeout'] : 0 ) ) + ); + + case 'table_info': + case 'table_xinfo': + $target = null !== $arg ? (string) $arg : (string) $value; + $result = $this->table_function_result( 'pragma_' . $name, array( $target ) ); + return null !== $result ? $result : $this->empty_result(); + + case 'index_list': + $target = null !== $arg ? (string) $arg : (string) $value; + $result = $this->table_function_result( 'pragma_index_list', array( $target ) ); + return null !== $result ? $result : $this->empty_result(); + + case 'index_info': + case 'index_xinfo': + $target = null !== $arg ? (string) $arg : (string) $value; + $result = $this->table_function_result( 'pragma_' . $name, array( $target ) ); + return null !== $result ? $result : $this->empty_result(); + + case 'database_list': + return array( + 'cols' => array( 'seq', 'name', 'file' ), + 'rows' => array( array( 0, 'main', null !== $this->path ? $this->path : '' ) ), + 'decl' => array( null, null, null ), + 'changes' => 0, + ); + + default: + // Store assignments; respond to known queries with defaults. + if ( null !== $value ) { + $this->pragma_values[ $name ] = $value; + return $this->empty_result(); + } + if ( isset( $this->pragma_values[ $name ] ) ) { + return $this->pragma_result( $name, array( array( $this->pragma_values[ $name ] ) ) ); + } + return $this->empty_result(); + } + } + + /** + * Build a single-column PRAGMA result. + * + * @param string $column The column name. + * @param array $rows The rows. + * @return array The result. + */ + private function pragma_result( $column, $rows ) { + return array( + 'cols' => array( $column ), + 'rows' => $rows, + 'decl' => array( null ), + 'changes' => 0, + ); + } + + /** + * Run a full foreign key check. + * + * @param string|null $only_table A lowercase table name to limit the check. + * @return array The violation rows: (table, rowid, parent, fkid). + */ + private function foreign_key_check( $only_table ) { + $only_key = null !== $only_table ? $this->resolve_table_key( $only_table ) : null; + $violations = array(); + foreach ( $this->db['tables'] as $lower => $table ) { + if ( null !== $only_key && $lower !== $only_key ) { + continue; + } + foreach ( $table['fks'] as $fk_index => $fk ) { + foreach ( $table['rows'] as $rowid => $row ) { + if ( ! $this->foreign_key_satisfied( $fk, $row ) ) { + $violations[] = array( $table['name'], $rowid, $fk['ref_table'], $fk_index ); + } + } + } + } + return $violations; + } + + /** + * Build the sqlite_sequence storage key for a table. + * + * @param array $table The table metadata. + * @return string The sequence storage key. + */ + private function sequence_key_for_table( $table ) { + return $this->sequence_key( $table['name'], ! empty( $table['temp'] ) ? 'temp' : 'main' ); + } + + /** + * Build a sqlite_sequence storage key for a table name and database. + * + * @param string $name The table name. + * @param string|null $db The database name (main or temp). + * @return string The sequence storage key. + */ + private function sequence_key( $name, $db = null ) { + return 'temp' === strtolower( (string) $db ) ? 'temp.' . $name : $name; + } + + /** + * Strip the internal temp marker from a sequence key. + * + * @param string $key The sequence storage key. + * @return string The table name exposed by sqlite_sequence. + */ + private function sequence_name_from_key( $key ) { + return 0 === strpos( $key, 'temp.' ) ? substr( $key, 5 ) : $key; + } + + /** + * Check whether a sequence key belongs to a selected database. + * + * @param string $key The sequence storage key. + * @param string|null $db The selected database. + * @return bool Whether the key belongs to the database. + */ + private function sequence_key_matches_db( $key, $db = null ) { + if ( 'temp' === strtolower( (string) $db ) ) { + return 0 === strpos( $key, 'temp.' ); + } + if ( 'main' === strtolower( (string) $db ) ) { + return 0 !== strpos( $key, 'temp.' ); + } + return true; + } + + /** + * Check whether sqlite_sequence exists in a selected database. + * + * @param string|null $db The selected database. + * @return bool Whether sqlite_sequence exists. + */ + private function sequence_table_exists( $db = null ) { + if ( 'temp' === strtolower( (string) $db ) ) { + return ! empty( $this->db['has_temp_sequence_table'] ); + } + if ( 'main' === strtolower( (string) $db ) ) { + return ! empty( $this->db['has_sequence_table'] ); + } + return ! empty( $this->db['has_sequence_table'] ) || ! empty( $this->db['has_temp_sequence_table'] ); + } + + /** + * Get a virtual table result by name (sqlite_master and friends). + * + * @param string $lower The lowercase table name. + * @param string|null $db The optional database name (main or temp). + * @return array|null The result set, or null if not a virtual table. + */ + public function virtual_table_result( $lower, $db = null ) { + if ( 'sqlite_master' === $lower || 'sqlite_schema' === $lower || 'sqlite_temp_master' === $lower || 'sqlite_temp_schema' === $lower ) { + $want_temp = 'sqlite_temp_master' === $lower || 'sqlite_temp_schema' === $lower; + $rows = array(); + $rootpage = 2; + + $has_autoincrement = false; + foreach ( $this->db['tables'] as $table_key => $table ) { + if ( ( 0 === strpos( $table_key, 'temp.' ) ) !== $want_temp ) { + continue; + } + if ( $table['autoincrement'] ) { + $has_autoincrement = true; + } + $rows[] = array( 'table', $table['name'], $table['name'], $rootpage, $table['sql'] ); + $rootpage += 1; + } + if ( $has_autoincrement ) { + $rows[] = array( 'table', 'sqlite_sequence', 'sqlite_sequence', $rootpage, 'CREATE TABLE sqlite_sequence(name,seq)' ); + $rootpage += 1; + } + foreach ( $this->db['indexes'] as $index_key => $index ) { + if ( ( 0 === strpos( $index_key, 'temp.' ) ) !== $want_temp || ! isset( $this->db['tables'][ $index['tbl'] ] ) ) { + continue; + } + $table = $this->db['tables'][ $index['tbl'] ]; + $rows[] = array( 'index', $index['name'], $table['name'], $rootpage, $index['sql'] ); + $rootpage += 1; + } + foreach ( $this->db['triggers'] as $trigger ) { + $is_temp = ! empty( $trigger['temp'] ); + if ( $is_temp !== $want_temp ) { + continue; + } + $rows[] = array( 'trigger', $trigger['name'], $trigger['tbl'], 0, $trigger['sql'] ); + } + foreach ( $this->db['views'] as $view ) { + if ( $want_temp ) { + continue; + } + $rows[] = array( 'view', $view['name'], $view['name'], 0, $view['sql'] ); + } + return array( + 'cols' => array( 'type', 'name', 'tbl_name', 'rootpage', 'sql' ), + 'rows' => $rows, + 'decl' => array( 'TEXT', 'TEXT', 'TEXT', 'INT', 'TEXT' ), + ); + } + + if ( 'sqlite_sequence' === $lower ) { + // The sqlite_sequence table only exists once an AUTOINCREMENT + // table has been created in the selected database. + if ( ! $this->sequence_table_exists( $db ) ) { + return null; + } + $rows = array(); + foreach ( $this->db['sequences'] as $key => $seq ) { + if ( ! $this->sequence_key_matches_db( $key, $db ) ) { + continue; + } + $rows[] = array( $this->sequence_name_from_key( $key ), $seq ); + } + return array( + 'cols' => array( 'name', 'seq' ), + 'rows' => $rows, + 'decl' => array( null, null ), + ); + } + + return null; + } + + /** + * Delete rows from the sqlite_sequence virtual table. + * + * This is used by DELETE statements that target sqlite_sequence. + * + * @param string|null $name The sequence (table) name, or null for all. + */ + public function delete_sequences( $name, $db = null ) { + if ( null === $name ) { + foreach ( array_keys( $this->db['sequences'] ) as $key ) { + if ( $this->sequence_key_matches_db( $key, $db ) ) { + unset( $this->db['sequences'][ $key ] ); + } + } + return; + } + unset( $this->db['sequences'][ $this->sequence_key( $name, $db ) ] ); + } + + /** + * Get a table-valued function result. + * + * @param string $name The lowercase function name. + * @param array $args The evaluated arguments. + * @return array|null The result set, or null for unknown functions. + */ + public function table_function_result( $name, $args ) { + switch ( $name ) { + case 'pragma_table_info': + case 'pragma_table_xinfo': + $table = $this->get_table( strtolower( (string) $args[0] ) ); + if ( null === $table ) { + return array( + 'cols' => array( 'cid', 'name', 'type', 'notnull', 'dflt_value', 'pk' ), + 'rows' => array(), + 'decl' => array( null, null, null, null, null, null ), + ); + } + $rows = array(); + $cid = 0; + foreach ( $table['columns'] as $col_lower => $column ) { + $pk = 0; + if ( null !== $table['pk_cols'] ) { + $position = array_search( $col_lower, $table['pk_cols'], true ); + if ( false !== $position ) { + $pk = $position + 1; + } + } + $dflt = null; + if ( $column['has_default'] ) { + $dflt = isset( $column['default_text'] ) && null !== $column['default_text'] + ? $column['default_text'] + : $this->render_expr_sql( $column['default'] ); + } + $row = array( + $cid, + $column['name'], + null !== $column['type'] ? $column['type'] : '', + $column['notnull'] ? 1 : 0, + $dflt, + $pk, + ); + if ( 'pragma_table_xinfo' === $name ) { + $row[] = 0; // hidden. + } + $rows[] = $row; + $cid += 1; + } + $cols = array( 'cid', 'name', 'type', 'notnull', 'dflt_value', 'pk' ); + if ( 'pragma_table_xinfo' === $name ) { + $cols[] = 'hidden'; + } + return array( + 'cols' => $cols, + 'rows' => $rows, + 'decl' => array_fill( 0, count( $cols ), null ), + ); + + case 'pragma_index_list': + $table_lower = $this->resolve_table_key( strtolower( (string) $args[0] ) ); + $rows = array(); + $seq = 0; + $indexes = array_reverse( $this->db['indexes'], true ); + foreach ( $indexes as $index ) { + if ( $index['tbl'] !== $table_lower ) { + continue; + } + $rows[] = array( $seq, $index['name'], $index['unique'] ? 1 : 0, $index['origin'], 0 ); + $seq += 1; + } + return array( + 'cols' => array( 'seq', 'name', 'unique', 'origin', 'partial' ), + 'rows' => $rows, + 'decl' => array( null, null, null, null, null ), + ); + + case 'pragma_index_info': + case 'pragma_index_xinfo': + $index_lower = strtolower( (string) $args[0] ); + if ( isset( $this->db['indexes'][ 'temp.' . $index_lower ] ) ) { + $index_lower = 'temp.' . $index_lower; + } + if ( ! isset( $this->db['indexes'][ $index_lower ] ) ) { + $cols = 'pragma_index_xinfo' === $name + ? array( 'seqno', 'cid', 'name', 'desc', 'coll', 'key' ) + : array( 'seqno', 'cid', 'name' ); + return array( + 'cols' => $cols, + 'rows' => array(), + 'decl' => array_fill( 0, count( $cols ), null ), + ); + } + $index = $this->db['indexes'][ $index_lower ]; + $table = $this->get_table( $index['tbl'] ); + $rows = array(); + $seqno = 0; + foreach ( $index['cols'] as $col ) { + $cid = -1; + $col_lower = strtolower( $col['name'] ); + if ( null !== $table ) { + $position = array_search( $col_lower, array_keys( $table['columns'] ), true ); + if ( false !== $position ) { + $cid = $position; + } + } + $col_name = null !== $table && isset( $table['columns'][ $col_lower ] ) + ? $table['columns'][ $col_lower ]['name'] + : $col['name']; + $collation = $col['collate']; + if ( null === $collation && null !== $table && isset( $table['columns'][ $col_lower ] ) ) { + $collation = $table['columns'][ $col_lower ]['collate']; + } + if ( 'pragma_index_xinfo' === $name ) { + $rows[] = array( $seqno, $cid, $col_name, 'DESC' === $col['dir'] ? 1 : 0, null !== $collation ? $collation : 'BINARY', 1 ); + } else { + $rows[] = array( $seqno, $cid, $col_name ); + } + $seqno += 1; + } + if ( 'pragma_index_xinfo' === $name ) { + return array( + 'cols' => array( 'seqno', 'cid', 'name', 'desc', 'coll', 'key' ), + 'rows' => $rows, + 'decl' => array( null, null, null, null, null, null ), + ); + } + return array( + 'cols' => array( 'seqno', 'cid', 'name' ), + 'rows' => $rows, + 'decl' => array( null, null, null ), + ); + } + return null; + } + + /* + * ---------------------------------------------------------------------- + * Persistence and file locking. + * + * The database file format is a single header line followed by the + * serialized database state: + * + * WP_PHP_ENGINE|1|\n + * + * The generation token changes on every save. Connections compare it + * against the last token they saw to detect (and reload) state saved + * by other processes. All access happens through one persistent file + * handle, which also carries the flock()-based locks — shared for + * reads and exclusive for the whole read-modify-write cycle of write + * statements (or a whole transaction). + * ---------------------------------------------------------------------- + */ + + /** + * The database file format magic prefix. + */ + const FILE_MAGIC = 'WP_PHP_ENGINE|1|'; + + /** + * Set the busy timeout used when waiting for file locks. + * + * @param int|float $seconds Seconds to wait before reporting SQLITE_BUSY. + */ + public function set_busy_timeout( $seconds ) { + $this->busy_timeout = max( 0.0, (float) $seconds ); + } + + /** + * Acquire a shared read lock within the busy timeout. + */ + private function acquire_shared_lock() { + $this->acquire_file_lock( LOCK_SH ); + } + + /** + * Acquire the exclusive write lock within the busy timeout. + */ + private function acquire_write_lock() { + if ( $this->write_lock_held ) { + return; + } + $this->acquire_file_lock( LOCK_EX ); + $this->write_lock_held = true; + } + + /** + * Acquire a file lock, waiting up to the configured busy timeout. + * + * @param int $operation The flock() operation. + */ + private function acquire_file_lock( $operation ) { + $deadline = microtime( true ) + $this->busy_timeout; + do { + $would_block = null; + if ( flock( $this->file_handle, $operation | LOCK_NB, $would_block ) ) { + return; + } + if ( microtime( true ) >= $deadline ) { + break; + } + usleep( 10000 ); + } while ( true ); + + throw new WP_PHP_Engine_SQL_Exception( 'database is locked', 'HY000', 5 ); + } + + /** + * Release the exclusive write lock. + */ + private function release_write_lock() { + if ( ! $this->write_lock_held ) { + return; + } + flock( $this->file_handle, LOCK_UN ); + $this->write_lock_held = false; + } + + /** + * Reload the database state when another process has saved a newer one. + * + * Must be called with at least a shared lock held, and never inside + * a transaction. + * + * @throws WP_PHP_Engine_SQL_Exception When the file is not a WP_PHP_Engine database. + */ + private function reload_if_changed() { + rewind( $this->file_handle ); + $header = fgets( $this->file_handle ); + if ( false === $header || '' === trim( $header ) ) { + // A new, empty database file. + return; + } + if ( 0 !== strpos( $header, self::FILE_MAGIC ) ) { + throw new WP_PHP_Engine_SQL_Exception( + 'file is not a WP_PHP_Engine database (refusing to overwrite an unrecognized file)', + 'HY000', + 26 // SQLITE_NOTADB. + ); + } + $generation = trim( substr( $header, strlen( self::FILE_MAGIC ) ) ); + if ( $generation === $this->file_generation ) { + return; + } + + $data = stream_get_contents( $this->file_handle ); + $db = false !== $data ? unserialize( $data ) : false; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize + if ( ! is_array( $db ) || ! isset( $db['tables'] ) ) { + throw new WP_PHP_Engine_SQL_Exception( 'database disk image is malformed', 'HY000', 11 ); + } + + // Carry over this connection's temporary tables and indexes. + foreach ( $this->db['tables'] as $key => $table ) { + if ( 0 === strpos( $key, 'temp.' ) ) { + $db['tables'][ $key ] = $table; + } + } + foreach ( $this->db['indexes'] as $key => $index ) { + if ( 0 === strpos( $key, 'temp.' ) ) { + $db['indexes'][ $key ] = $index; + } + } + + $this->db = $db; + $this->file_generation = $generation; + } + + /** + * Save the database state to disk (for file-backed databases). + * + * Must be called with the exclusive write lock held. + */ + private function save_to_disk() { + if ( null === $this->file_handle ) { + return; + } + + // Temporary tables and indexes are not persisted. + $db = $this->db; + foreach ( $db['tables'] as $lower => $table ) { + if ( 0 === strpos( $lower, 'temp.' ) ) { + unset( $db['tables'][ $lower ] ); + } + } + foreach ( $db['indexes'] as $lower => $index ) { + if ( 0 === strpos( $lower, 'temp.' ) ) { + unset( $db['indexes'][ $lower ] ); + } + } + + $generation = uniqid( '', true ); + $payload = self::FILE_MAGIC . $generation . "\n" . serialize( $db ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize + + rewind( $this->file_handle ); + ftruncate( $this->file_handle, 0 ); + fwrite( $this->file_handle, $payload ); // phpcs:ignore WordPress.WP.AlternativeFunctions + fflush( $this->file_handle ); + + $this->file_generation = $generation; + } +} + +/** + * A wrapper exception marking constraint violations that OR IGNORE can skip. + */ +class WP_PHP_Engine_Constraint_Exception extends Exception { + /** + * The wrapped exception. + * + * @var WP_PHP_Engine_SQL_Exception + */ + public $inner; + + /** + * Constructor. + * + * @param WP_PHP_Engine_SQL_Exception $inner The wrapped exception. + */ + public function __construct( $inner ) { + parent::__construct( $inner->getMessage() ); + $this->inner = $inner; + } +} diff --git a/packages/mysql-on-sqlite/src/php-engine/load.php b/packages/mysql-on-sqlite/src/php-engine/load.php new file mode 100644 index 00000000..a38adb2b --- /dev/null +++ b/packages/mysql-on-sqlite/src/php-engine/load.php @@ -0,0 +1,23 @@ + $pdo ) ); + * $driver = new WP_SQLite_Driver( $connection, 'wp' ); + */ + +require_once __DIR__ . '/class-wp-php-engine-lexer.php'; +require_once __DIR__ . '/class-wp-php-engine-parser.php'; +require_once __DIR__ . '/class-wp-php-engine-values.php'; +require_once __DIR__ . '/class-wp-php-engine-functions.php'; +require_once __DIR__ . '/class-wp-php-engine-evaluator.php'; +require_once __DIR__ . '/class-wp-php-engine.php'; +require_once __DIR__ . '/class-wp-php-engine-pdo.php'; +require_once __DIR__ . '/class-wp-php-engine-pdo-statement.php'; diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php index 7130fb63..d8eab702 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php @@ -675,6 +675,11 @@ public function __construct( $this->connection = new WP_SQLite_Connection( array( 'path' => $path ) ); } + // Match the default PDO SQLite behavior expected by the PDO facade tests. + if ( $this->connection->get_pdo() instanceof WP_PHP_Engine_PDO ) { + $this->connection->get_pdo()->setAttribute( PDO::ATTR_EMULATE_PREPARES, true ); + } + $this->mysql_version = $options['mysql_version'] ?? 80038; $this->main_db_name = $db_name; $this->db_name = $db_name; @@ -880,59 +885,74 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar throw $this->new_driver_exception( 'Failed to parse the MySQL query.' ); } - if ( $parser->next_query() ) { - throw $this->new_driver_exception( 'Multi-query is not supported.' ); + $asts = array( $ast ); + while ( $parser->next_query() ) { + $ast = $parser->get_query_ast(); + if ( null === $ast ) { + throw $this->new_driver_exception( 'Failed to parse the MySQL query.' ); + } + $asts[] = $ast; } - /* - * Determine if we need to wrap the translated queries in a transaction. - * - * [GRAMMAR] - * query: - * EOF - * | (simpleStatement | beginWork) (SEMICOLON_SYMBOL EOF? | EOF) - */ - $child_node = $ast->get_first_child_node(); - if ( - null === $child_node - || 'beginWork' === $child_node->rule_name - || $child_node->has_child_node( 'transactionOrLockingStatement' ) - || $child_node->has_child_node( 'selectStatement' ) - ) { - $wrap_in_transaction = false; - } else { - $wrap_in_transaction = true; - } + foreach ( $asts as $i => $ast ) { + if ( $i > 0 ) { + $this->last_result_statement = null; + $this->last_affected_rows = null; + $this->last_column_meta = array(); + $this->is_readonly = false; + $this->wrapper_transaction_type = null; + } - /* - * Detect read-only statements before opening the wrapper transaction. - * - * [GRAMMAR] - * simpleStatement: selectStatement | showStatement | utilityStatement | ... - */ - if ( null !== $child_node && $child_node->has_child_node() ) { - $statement_node = $child_node->get_first_child_node(); + /* + * Determine if we need to wrap the translated queries in a transaction. + * + * [GRAMMAR] + * query: + * EOF + * | (simpleStatement | beginWork) (SEMICOLON_SYMBOL EOF? | EOF) + */ + $child_node = $ast->get_first_child_node(); if ( - 'selectStatement' === $statement_node->rule_name - || 'showStatement' === $statement_node->rule_name + null === $child_node + || 'beginWork' === $child_node->rule_name + || $child_node->has_child_node( 'transactionOrLockingStatement' ) + || $child_node->has_child_node( 'selectStatement' ) ) { - $this->is_readonly = true; - } elseif ( 'utilityStatement' === $statement_node->rule_name ) { - $utility_subnode = $statement_node->get_first_child_node(); - if ( null !== $utility_subnode && 'describeStatement' === $utility_subnode->rule_name ) { + $wrap_in_transaction = false; + } else { + $wrap_in_transaction = true; + } + + /* + * Detect read-only statements before opening the wrapper transaction. + * + * [GRAMMAR] + * simpleStatement: selectStatement | showStatement | utilityStatement | ... + */ + if ( null !== $child_node && $child_node->has_child_node() ) { + $statement_node = $child_node->get_first_child_node(); + if ( + 'selectStatement' === $statement_node->rule_name + || 'showStatement' === $statement_node->rule_name + ) { $this->is_readonly = true; + } elseif ( 'utilityStatement' === $statement_node->rule_name ) { + $utility_subnode = $statement_node->get_first_child_node(); + if ( null !== $utility_subnode && 'describeStatement' === $utility_subnode->rule_name ) { + $this->is_readonly = true; + } } } - } - if ( $wrap_in_transaction ) { - $this->begin_wrapper_transaction(); - } + if ( $wrap_in_transaction ) { + $this->begin_wrapper_transaction(); + } - $this->execute_mysql_query( $ast ); + $this->execute_mysql_query( $ast ); - if ( $wrap_in_transaction ) { - $this->commit_wrapper_transaction(); + if ( $wrap_in_transaction ) { + $this->commit_wrapper_transaction(); + } } if ( null === $this->last_result_statement ) { @@ -1376,6 +1396,17 @@ public function execute_sqlite_query( string $sql, array $params = array() ): PD return $this->connection->query( $sql, $params ); } + /** + * Ensure the emulated information schema matches the SQLite schema. + */ + private function ensure_correct_information_schema(): void { + $reconstructor = new WP_SQLite_Information_Schema_Reconstructor( + $this, + $this->information_schema_builder + ); + $reconstructor->ensure_correct_information_schema(); + } + /** * Translate and execute a MySQL query in SQLite. * @@ -1436,20 +1467,27 @@ private function execute_mysql_query( WP_Parser_Node $node ): void { $subtree = $node->get_first_child_node(); switch ( $subtree->rule_name ) { case 'createDatabase': - /* - * TODO: - * We could support this by creating a new SQLite database - * file (e.g., $slugified_db_name.sqlite). - * - * Alternatively, it could be a no-op, in combination with - * DROP DATABASE deleting the data file and recreating it. - */ + $this->execute_create_database_statement( $node ); + break; case 'createTable': $this->execute_create_table_statement( $node ); break; case 'createIndex': $this->execute_create_index_statement( $node ); break; + case 'createView': + $this->execute_create_view_statement( $node ); + break; + case 'createTrigger': + $this->execute_create_trigger_statement( $node ); + break; + case 'createProcedure': + case 'createFunction': + $this->execute_create_routine_statement( $node ); + break; + case 'createEvent': + $this->execute_create_event_statement( $node ); + break; default: throw $this->new_not_supported_exception( sprintf( @@ -1479,12 +1517,29 @@ private function execute_mysql_query( WP_Parser_Node $node ): void { case 'dropStatement': $subtree = $node->get_first_child_node(); switch ( $subtree->rule_name ) { + case 'dropDatabase': + $this->execute_drop_database_statement( $node ); + break; case 'dropTable': $this->execute_drop_table_statement( $node ); break; case 'dropIndex': $this->execute_drop_index_statement( $node ); break; + case 'dropView': + $this->last_result_statement = $this->execute_sqlite_query( $this->translate( $node ) ); + $this->ensure_correct_information_schema(); + break; + case 'dropTrigger': + $this->execute_drop_trigger_statement( $node ); + break; + case 'dropProcedure': + case 'dropFunction': + $this->execute_drop_routine_statement( $node ); + break; + case 'dropEvent': + $this->execute_drop_event_statement( $node ); + break; default: $query = $this->translate( $node ); $this->last_result_statement = $this->execute_sqlite_query( $query ); @@ -1521,6 +1576,9 @@ private function execute_mysql_query( WP_Parser_Node $node ): void { case 'tableAdministrationStatement': $this->execute_administration_statement( $node ); break; + case 'callStatement': + $this->last_result_statement = $this->create_result_statement_from_data( array(), array() ); + break; default: throw $this->new_not_supported_exception( sprintf( 'statement type: "%s"', $node->rule_name ) @@ -2006,9 +2064,6 @@ function ( $column ) { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_update_statement( WP_Parser_Node $node ): void { - // @TODO: Add support for UPDATE with multiple tables and JOINs. - // SQLite supports them in the FROM clause. - $has_order = $node->has_child_node( 'orderClause' ); $has_limit = $node->has_child_node( 'simpleLimitClause' ); @@ -2047,21 +2102,6 @@ private function execute_update_statement( WP_Parser_Node $node ): void { $node->get_first_child_node( 'tableReferenceList' ) ); - /* - * Deny UPDATE for information schema tables. - * - * This basic approach is rather restrictive, as it blocks the usage - * of information schema tables anywhere in the UPDATE statement. - * - * TODO: Implement support for UPDATE statements like: - * UPDATE t, information_schema.columns c SET t.column = c.column ... - */ - foreach ( $table_alias_map as $alias => $data ) { - if ( 'information_schema' === strtolower( $data['database'] ?? '' ) ) { - throw $this->new_access_denied_to_information_schema_exception(); - } - } - // Determine whether the UPDATE statement modifies multiple tables. $update_list_node = $node->get_first_child_node( 'updateList' ); $update_target = null; @@ -2143,11 +2183,26 @@ private function execute_update_statement( WP_Parser_Node $node ): void { $update_target = array_keys( $table_alias_map )[0]; } - // TODO: Support UPDATE that modifies multiple tables. - // This is non-trivial and likely requires temporary tables. - // E.g.: UPDATE t1, t2 SET t1.id = t2.id, t2.id = t1.id; + if ( null === $update_target ) { + foreach ( $table_alias_map as $data ) { + if ( 'information_schema' === strtolower( $data['database'] ?? '' ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + } + + throw $this->new_not_supported_exception( 'UPDATE target could not be resolved' ); + } + + if ( + isset( $table_alias_map[ $update_target ] ) + && 'information_schema' === strtolower( $table_alias_map[ $update_target ]['database'] ?? '' ) + ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + if ( $updates_multiple_tables ) { - throw $this->new_not_supported_exception( 'UPDATE statement modifying multiple tables' ); + $this->execute_multi_table_update_statement( $node, $table_alias_map ); + return; } // Translate WITH clause. @@ -2182,6 +2237,12 @@ private function execute_update_statement( WP_Parser_Node $node ): void { continue; } + if ( 'information_schema' === strtolower( $data['database'] ?? '' ) ) { + $from_item = $data['table_expr'] . ' AS ' . $this->quote_sqlite_identifier( $alias ); + $from_items[] = $from_item; + continue; + } + // Regular table. $from_item = $this->quote_sqlite_identifier( $table_name ); if ( $alias !== $table_name ) { @@ -2217,6 +2278,44 @@ private function execute_update_statement( WP_Parser_Node $node ): void { $where_clause .= implode( ' AND ', $join_exprs ); } + $legacy_join_update_query = null; + // SQLite added UPDATE ... FROM in 3.33.0. Older supported builds still + // need joined UPDATEs, so keep the normal translated query for logging + // and prepare a correlated-subquery fallback for execution. + if ( null !== $from && version_compare( $this->get_sqlite_version(), '3.33.0', '<' ) ) { + $match_exprs = $join_exprs; + if ( $where_clause ) { + $match_exprs[] = preg_replace( '/^\s*WHERE\s+/i', '', $where_clause ); + } + $match_clause = count( $match_exprs ) > 0 ? ' WHERE ' . implode( ' AND ', $match_exprs ) : ''; + $from_clause = implode( ', ', $from_items ); + $assignments = array(); + foreach ( preg_split( '/\s*,\s*/', $update_list ) as $assignment ) { + $assignment_parts = explode( '=', $assignment, 2 ); + if ( 2 !== count( $assignment_parts ) ) { + throw $this->new_driver_exception( 'Unsupported UPDATE JOIN assignment for this SQLite version.' ); + } + $assignments[] = trim( $assignment_parts[0] ) . ' = ( SELECT ' . trim( $assignment_parts[1] ) . ' FROM ' . $from_clause . $match_clause . ' LIMIT 1 )'; + } + + $legacy_join_update_query = implode( + ' ', + array_filter( + array( + $with, + 'UPDATE', + $or_ignore, + $update_target_clause, + 'SET', + implode( ', ', $assignments ), + 'WHERE EXISTS ( SELECT 1 FROM ' . $from_clause . $match_clause . ' )', + $order_clause, + $limit_clause, + ) + ) + ); + } + // Compose the UPDATE query. $parts = array( $with, @@ -2232,7 +2331,157 @@ private function execute_update_statement( WP_Parser_Node $node ): void { ); $query = implode( ' ', array_filter( $parts ) ); - $this->last_result_statement = $this->execute_sqlite_query( $query ); + try { + $this->last_result_statement = $this->execute_sqlite_query( $query ); + } catch ( PDOException $e ) { + if ( null === $legacy_join_update_query || false === strpos( $e->getMessage(), 'near "FROM": syntax error' ) ) { + throw $e; + } + $stmt = $this->connection->get_pdo()->prepare( $legacy_join_update_query ); + $stmt->execute(); + $this->last_result_statement = $stmt; + } + } + + /** + * Execute a MySQL UPDATE statement that modifies multiple tables. + * + * @param WP_Parser_Node $node The "updateStatement" AST node. + * @param array $table_alias_map Table reference map from create_table_reference_map(). + */ + private function execute_multi_table_update_statement( WP_Parser_Node $node, array $table_alias_map ): void { + $assignments_by_alias = array(); + $update_list_node = $node->get_first_child_node( 'updateList' ); + foreach ( $update_list_node->get_child_nodes( 'updateElement' ) as $i => $update_element ) { + $column_ref = $update_element->get_first_child_node( 'columnRef' ); + $column_ref_parts = $column_ref->get_descendant_nodes( 'identifier' ); + $table_or_alias = count( $column_ref_parts ) > 1 + ? $this->unquote_sqlite_identifier( $this->translate( $column_ref_parts[0] ) ) + : null; + $column_name = $this->unquote_sqlite_identifier( $this->translate( end( $column_ref_parts ) ) ); + + if ( null === $table_or_alias ) { + $matching_aliases = array(); + foreach ( $table_alias_map as $alias => $data ) { + if ( null !== $data['table_name'] && $this->table_has_column( $data['table_name'], $column_name ) ) { + $matching_aliases[] = $alias; + } + } + if ( 1 !== count( $matching_aliases ) ) { + throw $this->new_driver_exception( + sprintf( + "SQLSTATE[23000]: Integrity constraint violation: 1052 Column '%s' in field list is ambiguous", + $column_name + ), + '23000' + ); + } + $table_or_alias = $matching_aliases[0]; + } elseif ( ! isset( $table_alias_map[ $table_or_alias ] ) ) { + foreach ( $table_alias_map as $alias => $data ) { + if ( $data['table_name'] === $table_or_alias ) { + $table_or_alias = $alias; + break; + } + } + } + + if ( ! isset( $table_alias_map[ $table_or_alias ] ) || null === $table_alias_map[ $table_or_alias ]['table_name'] ) { + if ( + isset( $table_alias_map[ $table_or_alias ] ) + && 'information_schema' === strtolower( $table_alias_map[ $table_or_alias ]['database'] ?? '' ) + ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + throw $this->new_not_supported_exception( 'multi-table UPDATE target is not a base table' ); + } + + $expr = $update_element->get_first_child_node( 'expr' ); + $value_alias = sprintf( '_wp_sqlite_update_value_%d', $i ); + $value_sql = null === $expr ? 'NULL' : $this->translate( $expr ); + + $assignments_by_alias[ $table_or_alias ][] = array( + 'column' => $column_name, + 'value_sql' => $value_sql, + 'value_alias' => $value_alias, + ); + } + + $select_list = array(); + foreach ( array_keys( $assignments_by_alias ) as $alias ) { + $select_list[] = sprintf( + '%s.rowid AS %s', + $this->quote_sqlite_identifier( $alias ), + $this->quote_sqlite_identifier( $alias . '_rowid' ) + ); + } + foreach ( $assignments_by_alias as $assignments ) { + foreach ( $assignments as $assignment ) { + $select_list[] = sprintf( + '%s AS %s', + $assignment['value_sql'], + $this->quote_sqlite_identifier( $assignment['value_alias'] ) + ); + } + } + + $where_clause = $this->translate( $node->get_first_child_node( 'whereClause' ) ); + $join_exprs = array_filter( array_column( $table_alias_map, 'join_expr' ) ); + if ( count( $join_exprs ) > 0 ) { + $where_clause .= $where_clause ? ' AND ' : ' WHERE '; + $where_clause .= implode( ' AND ', $join_exprs ); + } + + $rows = $this->execute_sqlite_query( + sprintf( + 'SELECT %s FROM %s %s %s %s', + implode( ', ', $select_list ), + $this->translate( $node->get_first_child_node( 'tableReferenceList' ) ), + $where_clause, + $this->translate( $node->get_first_child_node( 'orderClause' ) ), + $this->translate( $node->get_first_child_node( 'simpleLimitClause' ) ) + ) + )->fetchAll( PDO::FETCH_ASSOC ); + + $updates_by_alias_and_rowid = array(); + foreach ( $rows as $row ) { + foreach ( $assignments_by_alias as $alias => $assignments ) { + $rowid = $row[ $alias . '_rowid' ]; + foreach ( $assignments as $assignment ) { + $updates_by_alias_and_rowid[ $alias ][ $rowid ][ $assignment['column'] ] = $row[ $assignment['value_alias'] ]; + } + } + } + + $affected_rows = 0; + foreach ( $updates_by_alias_and_rowid as $alias => $updates_by_rowid ) { + $table_name = $table_alias_map[ $alias ]['table_name']; + foreach ( $updates_by_rowid as $rowid => $updates ) { + $set = array(); + foreach ( $updates as $column => $value ) { + $set[] = sprintf( + '%s = %s', + $this->quote_sqlite_identifier( $column ), + null === $value ? 'NULL' : $this->quote_sqlite_value( (string) $value ) + ); + } + + $stmt = $this->execute_sqlite_query( + sprintf( + 'UPDATE %s AS %s SET %s WHERE rowid = %s', + $this->quote_sqlite_identifier( $table_name ), + $this->quote_sqlite_identifier( $alias ), + implode( ', ', $set ), + $this->quote_sqlite_value( (string) $rowid ) + ) + ); + $affected_rows += $stmt->rowCount(); + } + } + + $this->last_result_statement = $this->create_result_statement_from_data( array(), array() ); + $this->last_affected_rows = $affected_rows; } /** @@ -2270,7 +2519,9 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { $table_ref = $single_table->get_first_child_node( 'tableRef' ); $alias_node = $single_table->get_first_child_node( 'tableAlias' ); if ( $alias_node ) { - $alias = $this->unquote_sqlite_identifier( $this->translate( $alias_node ) ); + $alias = $this->unquote_sqlite_identifier( + $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) + ); } else { $alias = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); } @@ -2376,6 +2627,71 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { $this->last_result_statement = $this->execute_sqlite_query( $query ); } + /** + * Record a MySQL CREATE DATABASE statement in INFORMATION_SCHEMA.SCHEMATA. + * + * SQLite still uses a single backing database; this supports migration code + * that creates, drops, and switches logical schemas before running queries. + * + * @param WP_Parser_Node $node The "createStatement" AST node with "createDatabase" child. + */ + private function execute_create_database_statement( WP_Parser_Node $node ): void { + $create_database = $node->get_first_child_node( 'createDatabase' ); + $database_name = strtolower( $this->get_object_name( $create_database->get_first_child_node( 'schemaName' ) ) ); + $charset = 'utf8mb4'; + $collation = WP_SQLite_Information_Schema_Builder::CHARSET_DEFAULT_COLLATION_MAP[ $charset ]; + + foreach ( $create_database->get_child_nodes( 'createDatabaseOption' ) as $option ) { + $charset_node = $option->get_first_descendant_node( 'charsetName' ); + if ( $charset_node ) { + $charset = strtolower( $this->get_object_name( $charset_node ) ); + $collation = WP_SQLite_Information_Schema_Builder::CHARSET_DEFAULT_COLLATION_MAP[ $charset ] ?? $collation; + } + + $collation_node = $option->get_first_descendant_node( 'collationName' ); + if ( $collation_node ) { + $collation = strtolower( $this->get_object_name( $collation_node ) ); + } + } + + $schemata_table = $this->information_schema_builder->get_table_name( false, 'schemata' ); + $this->last_result_statement = $this->execute_sqlite_query( + sprintf( + 'INSERT OR REPLACE INTO %s ( + schema_name, default_character_set_name, default_collation_name + ) VALUES ( ?, ?, ? )', + $this->quote_sqlite_identifier( $schemata_table ) + ), + array( $database_name, $charset, $collation ) + ); + } + + /** + * Record a MySQL DROP DATABASE statement in INFORMATION_SCHEMA.SCHEMATA. + * + * @param WP_Parser_Node $node The "dropStatement" AST node with "dropDatabase" child. + */ + private function execute_drop_database_statement( WP_Parser_Node $node ): void { + $drop_database = $node->get_first_child_node( 'dropDatabase' ); + $database_name = strtolower( $this->get_object_name( $drop_database->get_first_child_node( 'schemaRef' ) ) ); + + if ( $this->main_db_name === $database_name || 'information_schema' === $database_name ) { + throw $this->new_not_supported_exception( "DROP DATABASE for built-in schema '$database_name'" ); + } + + $schemata_table = $this->information_schema_builder->get_table_name( false, 'schemata' ); + $this->last_result_statement = $this->execute_sqlite_query( + sprintf( + 'DELETE FROM %s WHERE schema_name = ?', + $this->quote_sqlite_identifier( $schemata_table ) + ), + array( $database_name ) + ); + if ( $this->db_name === $database_name ) { + $this->db_name = $this->main_db_name; + } + } + /** * Translate and execute a MySQL CREATE TABLE statement in SQLite. * @@ -2391,16 +2707,9 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { // Handle CREATE TABLE ... [AS] SELECT. $element_list = $subnode->get_first_child_node( 'tableElementList' ); if ( null === $element_list ) { - /* - * While SQLite supports CREATE TABLE ... AS SELECT statements, - * we need to somehow implement information schema support for - * the tables created in this way. - * - * TODO: Implement information schema support for CREATE TABLE ... AS SELECT. - */ - throw $this->new_not_supported_exception( - 'CREATE TABLE ... [AS] SELECT is currently not supported' - ); + $this->last_result_statement = $this->execute_sqlite_query( $this->translate( $node ) ); + $this->ensure_correct_information_schema(); + return; } // Get table name. @@ -2447,6 +2756,250 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { $this->apply_auto_increment_table_option( $table_is_temporary, $table_name, $node ); } + /** + * Translate and execute a MySQL CREATE VIEW statement in SQLite. + * + * @param WP_Parser_Node $node The "createStatement" AST node with "createView" child. + */ + private function execute_create_view_statement( WP_Parser_Node $node ): void { + $view_ref = $node->get_first_descendant_node( 'viewRef' ); + if ( $view_ref && 'information_schema' === strtolower( $this->get_database_name( $view_ref ) ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $this->last_result_statement = $this->execute_sqlite_query( $this->translate( $node ) ); + $this->ensure_correct_information_schema(); + } + + /** + * Translate and execute a MySQL CREATE TRIGGER statement in SQLite. + * + * @param WP_Parser_Node $node The "createStatement" AST node with "createTrigger" child. + */ + private function execute_create_trigger_statement( WP_Parser_Node $node ): void { + $create_trigger = $node->get_first_child_node( 'createTrigger' ); + $trigger_name = $this->get_object_name( $create_trigger->get_first_child_node( 'triggerName' ) ); + $trigger_schema = $this->get_database_name( $create_trigger->get_first_child_node( 'triggerName' ) ); + $table_ref = $create_trigger->get_first_child_node( 'tableRef' ); + $table_schema = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + + if ( 'information_schema' === strtolower( $trigger_schema ) || 'information_schema' === strtolower( $table_schema ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $this->last_result_statement = $this->execute_sqlite_query( $this->translate( $node ) ); + + $trigger_table = $this->information_schema_builder->get_table_name( false, 'triggers' ); + $this->execute_sqlite_query( + sprintf( + 'DELETE FROM %s WHERE trigger_schema = ? AND trigger_name = ?', + $this->quote_sqlite_identifier( $trigger_table ) + ), + array( $this->get_saved_db_name( $trigger_schema ), $trigger_name ) + ); + $this->execute_sqlite_query( + sprintf( + 'INSERT INTO %s ( + trigger_schema, trigger_name, event_manipulation, event_object_schema, + event_object_table, action_statement, action_timing, created, sql_mode + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ? )', + $this->quote_sqlite_identifier( $trigger_table ) + ), + array( + $this->get_saved_db_name( $trigger_schema ), + $trigger_name, + $this->get_direct_child_token_value( + $create_trigger, + array( WP_MySQL_Lexer::INSERT_SYMBOL, WP_MySQL_Lexer::UPDATE_SYMBOL, WP_MySQL_Lexer::DELETE_SYMBOL ) + ), + $this->get_saved_db_name( $table_schema ), + $table_name, + $this->translate( $create_trigger->get_first_child_node( 'compoundStatement' ) ), + $this->get_direct_child_token_value( + $create_trigger, + array( WP_MySQL_Lexer::BEFORE_SYMBOL, WP_MySQL_Lexer::AFTER_SYMBOL ) + ), + implode( ',', $this->active_sql_modes ), + ) + ); + } + + /** + * Execute a MySQL DROP TRIGGER statement in SQLite. + * + * @param WP_Parser_Node $node The "dropStatement" AST node with "dropTrigger" child. + */ + private function execute_drop_trigger_statement( WP_Parser_Node $node ): void { + $drop_trigger = $node->get_first_child_node( 'dropTrigger' ); + $trigger_ref = $drop_trigger->get_first_child_node( 'triggerRef' ); + $database = $this->get_database_name( $trigger_ref ); + $trigger_name = $this->get_object_name( $trigger_ref ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $this->last_result_statement = $this->execute_sqlite_query( $this->translate( $node ) ); + $trigger_table = $this->information_schema_builder->get_table_name( false, 'triggers' ); + $this->execute_sqlite_query( + sprintf( + 'DELETE FROM %s WHERE trigger_schema = ? AND trigger_name = ?', + $this->quote_sqlite_identifier( $trigger_table ) + ), + array( $this->get_saved_db_name( $database ), $trigger_name ) + ); + } + + /** + * Record a stored procedure or function in INFORMATION_SCHEMA.ROUTINES. + * + * The pure SQLite backend does not execute stored routines. Recording them + * lets migration and introspection queries create/drop routines without + * failing while CALL is treated as an empty operation. + * + * @param WP_Parser_Node $node The "createStatement" AST node with routine child. + */ + private function execute_create_routine_statement( WP_Parser_Node $node ): void { + $routine = $node->get_first_child_node(); + $is_function = 'createFunction' === $routine->rule_name; + $name_node = $routine->get_first_child_node( $is_function ? 'functionName' : 'procedureName' ); + $database = $this->get_database_name( $name_node ); + $routine_name = $this->get_object_name( $name_node ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $routines_table = $this->information_schema_builder->get_table_name( false, 'routines' ); + $routine_type = $is_function ? 'FUNCTION' : 'PROCEDURE'; + $data_type = ''; + $type_node = $routine->get_first_descendant_node( 'dataType' ); + if ( $type_node ) { + $data_type = strtolower( $type_node->get_first_child_token()->get_value() ); + } + + $this->execute_sqlite_query( + sprintf( + 'DELETE FROM %s WHERE routine_schema = ? AND routine_name = ? AND routine_type = ?', + $this->quote_sqlite_identifier( $routines_table ) + ), + array( $this->get_saved_db_name( $database ), $routine_name, $routine_type ) + ); + $this->last_result_statement = $this->execute_sqlite_query( + sprintf( + 'INSERT INTO %s ( + specific_name, routine_schema, routine_name, routine_type, data_type, + dtd_identifier, routine_definition, sql_mode + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ? )', + $this->quote_sqlite_identifier( $routines_table ) + ), + array( + $routine_name, + $this->get_saved_db_name( $database ), + $routine_name, + $routine_type, + $data_type, + $data_type, + $this->translate( $routine->get_first_child_node( 'compoundStatement' ) ), + implode( ',', $this->active_sql_modes ), + ) + ); + } + + /** + * Remove a stored procedure or function from INFORMATION_SCHEMA.ROUTINES. + * + * @param WP_Parser_Node $node The "dropStatement" AST node with routine child. + */ + private function execute_drop_routine_statement( WP_Parser_Node $node ): void { + $routine = $node->get_first_child_node(); + $is_function = 'dropFunction' === $routine->rule_name; + $ref_node = $routine->get_first_child_node( $is_function ? 'functionRef' : 'procedureRef' ); + $database = $this->get_database_name( $ref_node ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $routines_table = $this->information_schema_builder->get_table_name( false, 'routines' ); + $this->last_result_statement = $this->execute_sqlite_query( + sprintf( + 'DELETE FROM %s WHERE routine_schema = ? AND routine_name = ? AND routine_type = ?', + $this->quote_sqlite_identifier( $routines_table ) + ), + array( + $this->get_saved_db_name( $database ), + $this->get_object_name( $ref_node ), + $is_function ? 'FUNCTION' : 'PROCEDURE', + ) + ); + } + + /** + * Record a MySQL event in INFORMATION_SCHEMA.EVENTS. + * + * @param WP_Parser_Node $node The "createStatement" AST node with "createEvent" child. + */ + private function execute_create_event_statement( WP_Parser_Node $node ): void { + $event = $node->get_first_child_node( 'createEvent' ); + $name_node = $event->get_first_child_node( 'eventName' ); + $database = $this->get_database_name( $name_node ); + $event_name = $this->get_object_name( $name_node ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $events_table = $this->information_schema_builder->get_table_name( false, 'events' ); + $this->execute_sqlite_query( + sprintf( + 'DELETE FROM %s WHERE event_schema = ? AND event_name = ?', + $this->quote_sqlite_identifier( $events_table ) + ), + array( $this->get_saved_db_name( $database ), $event_name ) + ); + $this->last_result_statement = $this->execute_sqlite_query( + sprintf( + 'INSERT INTO %s ( + event_schema, event_name, event_definition, event_type, sql_mode + ) VALUES ( ?, ?, ?, ?, ? )', + $this->quote_sqlite_identifier( $events_table ) + ), + array( + $this->get_saved_db_name( $database ), + $event_name, + $this->translate( $event->get_first_child_node( 'compoundStatement' ) ), + $event->get_first_descendant_node( 'interval' ) ? 'RECURRING' : 'ONE TIME', + implode( ',', $this->active_sql_modes ), + ) + ); + } + + /** + * Remove a MySQL event from INFORMATION_SCHEMA.EVENTS. + * + * @param WP_Parser_Node $node The "dropStatement" AST node with "dropEvent" child. + */ + private function execute_drop_event_statement( WP_Parser_Node $node ): void { + $drop_event = $node->get_first_child_node( 'dropEvent' ); + $event_ref = $drop_event->get_first_child_node( 'eventRef' ); + $database = $this->get_database_name( $event_ref ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $events_table = $this->information_schema_builder->get_table_name( false, 'events' ); + $this->last_result_statement = $this->execute_sqlite_query( + sprintf( + 'DELETE FROM %s WHERE event_schema = ? AND event_name = ?', + $this->quote_sqlite_identifier( $events_table ) + ), + array( $this->get_saved_db_name( $database ), $this->get_object_name( $event_ref ) ) + ); + } + /** * Translate and execute a MySQL ALTER TABLE statement in SQLite. * @@ -3130,21 +3683,45 @@ private function execute_show_tables_statement( WP_Parser_Node $node ): void { $is_full = $command_type && $command_type->has_child_token( WP_MySQL_Lexer::FULL_SYMBOL ); // Fetch table information. - $table_tables = $this->information_schema_builder->get_table_name( - false, // SHOW TABLES lists only non-temporary tables. - 'tables' - ); - $query = sprintf( - 'SELECT %s FROM %s WHERE table_schema = ? %s ORDER BY table_name', - $is_full - ? sprintf( 'table_name AS `Tables_in_%s`, table_type AS `Table_type`', $database ) - : sprintf( 'table_name AS `Tables_in_%s`', $database ), - $this->quote_sqlite_identifier( $table_tables ), - $condition ?? '' - ); - $params = array( - $this->get_saved_db_name( $database ), - ); + $fields = $is_full + ? sprintf( 'table_name AS `Tables_in_%s`, table_type AS `Table_type`', $database ) + : sprintf( 'table_name AS `Tables_in_%s`', $database ); + + $table_tables = $this->information_schema_builder->get_table_name( false, 'tables' ); + $temporary_table_tables = $this->information_schema_builder->get_table_name( true, 'tables' ); + $temporary_schema_exists = $this->execute_sqlite_query( + 'SELECT 1 FROM sqlite_temp_master WHERE type = \'table\' AND name = ?', + array( $temporary_table_tables ) + )->fetchColumn(); + + if ( $temporary_schema_exists ) { + $query = sprintf( + 'SELECT %s FROM ( + SELECT table_name, table_type FROM %s WHERE table_schema = ? %s + UNION + SELECT table_name, table_type FROM %s WHERE table_schema = ? %s + ) ORDER BY table_name', + $fields, + $this->quote_sqlite_identifier( $temporary_table_tables ), + $condition ?? '', + $this->quote_sqlite_identifier( $table_tables ), + $condition ?? '' + ); + $params = array( + $this->get_saved_db_name( $database ), + $this->get_saved_db_name( $database ), + ); + } else { + $query = sprintf( + 'SELECT %s FROM %s WHERE table_schema = ? %s ORDER BY table_name', + $fields, + $this->quote_sqlite_identifier( $table_tables ), + $condition ?? '' + ); + $params = array( + $this->get_saved_db_name( $database ), + ); + } $stmt = $this->execute_sqlite_query( $query, $params ); $this->store_last_column_meta_from_statement( $stmt ); @@ -3298,12 +3875,16 @@ private function execute_use_statement( WP_Parser_Node $node ): void { ); $database_name = strtolower( $database_name ); - if ( $this->main_db_name === $database_name || 'information_schema' === $database_name ) { + if ( + $this->main_db_name === $database_name + || 'information_schema' === $database_name + || $this->database_exists( $database_name ) + ) { $this->db_name = $database_name; } else { throw $this->new_not_supported_exception( sprintf( - "can't use schema '%s', only '%s' and 'information_schema' are supported", + "can't use schema '%s', only '%s', 'information_schema', and schemas created with CREATE DATABASE are supported", $database_name, $this->db_name ) @@ -4905,6 +5486,9 @@ public function translate_table_ref( WP_Parser_Node $node ): string { 'UNIQUE_CONSTRAINT_SCHEMA' => true, 'REFERENCED_TABLE_SCHEMA' => true, 'TRIGGER_SCHEMA' => true, + 'EVENT_OBJECT_SCHEMA' => true, + 'ROUTINE_SCHEMA' => true, + 'EVENT_SCHEMA' => true, ); $expanded_list = array(); @@ -4913,10 +5497,11 @@ public function translate_table_ref( WP_Parser_Node $node ): string { if ( isset( $information_schema_db_column_map[ $column ] ) ) { // Replace the database name with the configured database name. $expanded_list[] = sprintf( - "CASE WHEN %s = 'information_schema' THEN %s ELSE %s END AS %s", - $quoted_column, + 'CASE WHEN %s = %s THEN %s ELSE %s END AS %s', $quoted_column, + $this->quote_sqlite_value( WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME ), $this->quote_sqlite_value( $this->main_db_name ), + $quoted_column, $quoted_column ); } elseif ( 'tables' === $table_name && 'AUTO_INCREMENT' === $column ) { @@ -5889,15 +6474,20 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { if ( 'singleTable' === $child->rule_name ) { // Extract data from the "singleTable" node. - $table_ref = $child->get_first_child_node( 'tableRef' ); - $name = $this->translate( $table_ref ); - $alias_node = $child->get_first_child_node( 'tableAlias' ); - $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null; - - $table_map[ $this->unquote_sqlite_identifier( $alias ?? $name ) ] = array( - 'database' => $this->get_database_name( $table_ref ), - 'table_name' => $this->unquote_sqlite_identifier( $name ), - 'table_expr' => null, + $table_ref = $child->get_first_child_node( 'tableRef' ); + $name = $this->translate( $table_ref ); + $database = $this->get_database_name( $table_ref ); + $alias_node = $child->get_first_child_node( 'tableAlias' ); + $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null; + $is_info = 'information_schema' === strtolower( $database ); + $table_name = $is_info ? null : $this->unquote_sqlite_identifier( $name ); + $table_ref_parts = $table_ref->get_descendant_nodes( 'identifier' ); + $alias_key = $alias ?? ( $is_info ? $this->translate( end( $table_ref_parts ) ) : $name ); + + $table_map[ $this->unquote_sqlite_identifier( $alias_key ) ] = array( + 'database' => $database, + 'table_name' => $table_name, + 'table_expr' => $is_info ? $name : null, 'join_expr' => $this->translate( $join_expr ), ); } elseif ( 'derivedTable' === $child->rule_name ) { @@ -5921,6 +6511,44 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { return $table_map; } + /** + * Check whether a table has a column. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return bool True when the column exists. + */ + private function table_has_column( string $table_name, string $column_name ): bool { + $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); + $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' ); + + return false !== $this->execute_sqlite_query( + sprintf( + 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->quote_sqlite_identifier( $columns_table ) + ), + array( $this->get_saved_db_name(), $table_name, $column_name ) + )->fetchColumn(); + } + + /** + * Check whether a logical database exists in the emulated information schema. + * + * @param string $database_name Database name. + * @return bool True when the database exists. + */ + private function database_exists( string $database_name ): bool { + $schemata_table = $this->information_schema_builder->get_table_name( false, 'schemata' ); + + return false !== $this->execute_sqlite_query( + sprintf( + 'SELECT 1 FROM %s WHERE schema_name = ?', + $this->quote_sqlite_identifier( $schemata_table ) + ), + array( $this->get_saved_db_name( $database_name ) ) + )->fetchColumn(); + } + /** * Emulate MySQL type casting for values to be saved to the database * using INSERT, REPLACE, or UPDATE statements. @@ -6098,7 +6726,19 @@ private function get_saved_db_name( ?string $db_name = null ): string { * @return string The database name. */ private function get_database_name( WP_Parser_Node $node ): string { - if ( 'tableName' === $node->rule_name || 'tableRef' === $node->rule_name ) { + if ( + 'tableName' === $node->rule_name + || 'tableRef' === $node->rule_name + || 'viewRef' === $node->rule_name + || 'triggerName' === $node->rule_name + || 'triggerRef' === $node->rule_name + || 'procedureName' === $node->rule_name + || 'procedureRef' === $node->rule_name + || 'functionName' === $node->rule_name + || 'functionRef' === $node->rule_name + || 'eventName' === $node->rule_name + || 'eventRef' === $node->rule_name + ) { $parts = $node->get_descendant_nodes( 'identifier' ); if ( count( $parts ) > 1 ) { return $this->unquote_sqlite_identifier( $this->translate( $parts[0] ) ); @@ -6116,6 +6756,36 @@ private function get_database_name( WP_Parser_Node $node ): string { ); } + /** + * Get an unqualified object name from a possibly qualified reference node. + * + * @param WP_Parser_Node $node Object reference node. + * @return string Object name. + */ + private function get_object_name( WP_Parser_Node $node ): string { + $parts = $node->get_descendant_nodes( 'identifier' ); + return $this->unquote_sqlite_identifier( $this->translate( end( $parts ) ) ); + } + + /** + * Get the value of the first direct child token matching one of the IDs. + * + * @param WP_Parser_Node $node Node to scan. + * @param int[] $token_ids Token IDs to match. + * @return string Matched token value. + */ + private function get_direct_child_token_value( WP_Parser_Node $node, array $token_ids ): string { + foreach ( $node->get_children() as $child ) { + if ( $child instanceof WP_MySQL_Token && in_array( $child->id, $token_ids, true ) ) { + return strtoupper( $child->get_value() ); + } + } + + throw $this->new_driver_exception( + sprintf( 'Could not find expected token in node: %s', $node->rule_name ) + ); + } + /** * Generate a SQLite CREATE TABLE statement from information schema data. * diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index 1509a1e6..ea1dc431 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php @@ -76,8 +76,22 @@ public function __construct( array $options ) { 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'] ); + + /* + * When the PDO SQLite driver is not available, or when explicitly + * requested with the WP_SQLITE_FORCE_PHP_ENGINE constant, fall back + * to the bundled pure-PHP database engine. It refuses to open files + * in other formats, so it can never overwrite a real SQLite file. + */ + $force_php_engine = defined( 'WP_SQLITE_FORCE_PHP_ENGINE' ) && WP_SQLITE_FORCE_PHP_ENGINE; + $has_pdo_sqlite = in_array( 'sqlite', PDO::getAvailableDrivers(), true ); + if ( $force_php_engine || ! $has_pdo_sqlite ) { + require_once __DIR__ . '/../php-engine/load.php'; + $this->pdo = new WP_PHP_Engine_PDO( 'php-engine:' . $options['path'] ); + } else { + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->pdo = new $pdo_class( 'sqlite:' . $options['path'] ); + } } // Throw exceptions on error. diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index 8e84a9f2..4c366280 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -34,10 +34,6 @@ class WP_SQLite_Information_Schema_Builder { * - STATISTICS (indexes) * - TABLE_CONSTRAINTS * - CHECK_CONSTRAINTS - * - * TODO (not yet implemented): - * - VIEWS - * - TRIGGERS */ const INFORMATION_SCHEMA_TABLE_DEFINITIONS = array( // INFORMATION_SCHEMA.SCHEMATA @@ -188,6 +184,98 @@ class WP_SQLite_Information_Schema_Builder { CHECK_CLAUSE TEXT NOT NULL COLLATE BINARY, -- check clause PRIMARY KEY (CONSTRAINT_SCHEMA, CONSTRAINT_NAME) ", + + // INFORMATION_SCHEMA.TRIGGERS + 'triggers' => " + TRIGGER_CATALOG TEXT NOT NULL DEFAULT 'def' COLLATE NOCASE, -- always 'def' + TRIGGER_SCHEMA TEXT NOT NULL COLLATE NOCASE, -- trigger database name + TRIGGER_NAME TEXT NOT NULL COLLATE NOCASE, -- trigger name + EVENT_MANIPULATION TEXT NOT NULL COLLATE NOCASE, -- INSERT, UPDATE, or DELETE + EVENT_OBJECT_CATALOG TEXT NOT NULL DEFAULT 'def' COLLATE NOCASE, -- always 'def' + EVENT_OBJECT_SCHEMA TEXT NOT NULL COLLATE NOCASE, -- target table database name + EVENT_OBJECT_TABLE TEXT NOT NULL COLLATE NOCASE, -- target table name + ACTION_ORDER INTEGER NOT NULL DEFAULT 1, -- execution order + ACTION_CONDITION TEXT COLLATE BINARY, -- not implemented + ACTION_STATEMENT TEXT NOT NULL COLLATE BINARY, -- trigger body + ACTION_ORIENTATION TEXT NOT NULL DEFAULT 'ROW' COLLATE NOCASE, -- always ROW + ACTION_TIMING TEXT NOT NULL COLLATE NOCASE, -- BEFORE or AFTER + ACTION_REFERENCE_OLD_TABLE TEXT COLLATE NOCASE, -- not implemented + ACTION_REFERENCE_NEW_TABLE TEXT COLLATE NOCASE, -- not implemented + ACTION_REFERENCE_OLD_ROW TEXT NOT NULL DEFAULT 'OLD' COLLATE NOCASE, + ACTION_REFERENCE_NEW_ROW TEXT NOT NULL DEFAULT 'NEW' COLLATE NOCASE, + CREATED TEXT, -- creation timestamp + SQL_MODE TEXT NOT NULL DEFAULT '' COLLATE BINARY, -- active SQL mode at creation time + DEFINER TEXT NOT NULL DEFAULT '' COLLATE BINARY, -- not implemented + CHARACTER_SET_CLIENT TEXT NOT NULL DEFAULT 'utf8mb4' COLLATE NOCASE, + COLLATION_CONNECTION TEXT NOT NULL DEFAULT 'utf8mb4_0900_ai_ci' COLLATE NOCASE, + DATABASE_COLLATION TEXT NOT NULL DEFAULT 'utf8mb4_0900_ai_ci' COLLATE NOCASE, + PRIMARY KEY (TRIGGER_SCHEMA, TRIGGER_NAME) + ", + + // INFORMATION_SCHEMA.ROUTINES + 'routines' => " + SPECIFIC_NAME TEXT NOT NULL COLLATE NOCASE, -- routine name + ROUTINE_CATALOG TEXT NOT NULL DEFAULT 'def' COLLATE NOCASE, -- always 'def' + ROUTINE_SCHEMA TEXT NOT NULL COLLATE NOCASE, -- routine database name + ROUTINE_NAME TEXT NOT NULL COLLATE NOCASE, -- routine name + ROUTINE_TYPE TEXT NOT NULL COLLATE NOCASE, -- PROCEDURE or FUNCTION + DATA_TYPE TEXT NOT NULL DEFAULT '' COLLATE BINARY, -- function return type, empty for procedures + CHARACTER_MAXIMUM_LENGTH INTEGER, + CHARACTER_OCTET_LENGTH INTEGER, + NUMERIC_PRECISION INTEGER, + NUMERIC_SCALE INTEGER, + DATETIME_PRECISION INTEGER, + CHARACTER_SET_NAME TEXT COLLATE NOCASE, + COLLATION_NAME TEXT COLLATE NOCASE, + DTD_IDENTIFIER TEXT COLLATE BINARY, + ROUTINE_BODY TEXT NOT NULL DEFAULT 'SQL' COLLATE NOCASE, + ROUTINE_DEFINITION TEXT COLLATE BINARY, + EXTERNAL_NAME TEXT COLLATE BINARY, + EXTERNAL_LANGUAGE TEXT COLLATE NOCASE, + PARAMETER_STYLE TEXT NOT NULL DEFAULT 'SQL' COLLATE NOCASE, + IS_DETERMINISTIC TEXT NOT NULL DEFAULT 'NO' COLLATE NOCASE, + SQL_DATA_ACCESS TEXT NOT NULL DEFAULT 'CONTAINS SQL' COLLATE NOCASE, + SQL_PATH TEXT COLLATE NOCASE, + SECURITY_TYPE TEXT NOT NULL DEFAULT 'DEFINER' COLLATE NOCASE, + CREATED TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_ALTERED TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + SQL_MODE TEXT NOT NULL DEFAULT '' COLLATE BINARY, + ROUTINE_COMMENT TEXT NOT NULL DEFAULT '' COLLATE BINARY, + DEFINER TEXT NOT NULL DEFAULT '' COLLATE BINARY, + CHARACTER_SET_CLIENT TEXT NOT NULL DEFAULT 'utf8mb4' COLLATE NOCASE, + COLLATION_CONNECTION TEXT NOT NULL DEFAULT 'utf8mb4_0900_ai_ci' COLLATE NOCASE, + DATABASE_COLLATION TEXT NOT NULL DEFAULT 'utf8mb4_0900_ai_ci' COLLATE NOCASE, + PRIMARY KEY (ROUTINE_SCHEMA, ROUTINE_NAME, ROUTINE_TYPE) + ", + + // INFORMATION_SCHEMA.EVENTS + 'events' => " + EVENT_CATALOG TEXT NOT NULL DEFAULT 'def' COLLATE NOCASE, -- always 'def' + EVENT_SCHEMA TEXT NOT NULL COLLATE NOCASE, -- event database name + EVENT_NAME TEXT NOT NULL COLLATE NOCASE, -- event name + DEFINER TEXT NOT NULL DEFAULT '' COLLATE BINARY, + TIME_ZONE TEXT NOT NULL DEFAULT 'SYSTEM' COLLATE NOCASE, + EVENT_BODY TEXT NOT NULL DEFAULT 'SQL' COLLATE NOCASE, + EVENT_DEFINITION TEXT COLLATE BINARY, + EVENT_TYPE TEXT NOT NULL COLLATE NOCASE, -- ONE TIME or RECURRING + EXECUTE_AT TEXT, + INTERVAL_VALUE TEXT COLLATE BINARY, + INTERVAL_FIELD TEXT COLLATE NOCASE, + SQL_MODE TEXT NOT NULL DEFAULT '' COLLATE BINARY, + STARTS TEXT, + ENDS TEXT, + STATUS TEXT NOT NULL DEFAULT 'ENABLED' COLLATE NOCASE, + ON_COMPLETION TEXT NOT NULL DEFAULT 'NOT PRESERVE' COLLATE NOCASE, + CREATED TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_ALTERED TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_EXECUTED TEXT, + EVENT_COMMENT TEXT NOT NULL DEFAULT '' COLLATE BINARY, + ORIGINATOR INTEGER NOT NULL DEFAULT 0, + CHARACTER_SET_CLIENT TEXT NOT NULL DEFAULT 'utf8mb4' COLLATE NOCASE, + COLLATION_CONNECTION TEXT NOT NULL DEFAULT 'utf8mb4_0900_ai_ci' COLLATE NOCASE, + DATABASE_COLLATION TEXT NOT NULL DEFAULT 'utf8mb4_0900_ai_ci' COLLATE NOCASE, + PRIMARY KEY (EVENT_SCHEMA, EVENT_NAME) + ", ); /** @@ -661,7 +749,7 @@ public function record_alter_table( WP_Parser_Node $node ): void { $column_definitions = $action->get_descendant_nodes( 'columnDefinition' ); if ( count( $column_definitions ) > 0 ) { foreach ( $column_definitions as $column_definition ) { - $name = $this->get_value( $column_definition->get_first_child_node( 'identifier' ) ); + $name = $this->get_value( $column_definition->get_first_child_node( 'fieldIdentifier' ) ); $this->record_add_column( $table_is_temporary, $table_name, $name, $column_definition ); } continue; @@ -758,6 +846,30 @@ public function record_alter_table( WP_Parser_Node $node ): void { continue; } } + + // ALTER [COLUMN] ... SET/DROP DEFAULT + if ( WP_MySQL_Lexer::ALTER_SYMBOL === $first_token->id ) { + $column_ref = $action->get_first_child_node( 'fieldIdentifier' ); + $column_name = $this->get_value( $column_ref ); + + if ( $action->has_child_token( WP_MySQL_Lexer::DROP_SYMBOL ) ) { + $this->record_alter_column_default( $table_is_temporary, $table_name, $column_name, null, false ); + continue; + } + + if ( $action->has_child_token( WP_MySQL_Lexer::SET_SYMBOL ) ) { + $default_value = $this->get_default_value_from_node( $action ); + $is_generated = $this->is_default_value_generated( $action ); + $this->record_alter_column_default( + $table_is_temporary, + $table_name, + $column_name, + $default_value, + $is_generated + ); + continue; + } + } } } @@ -986,6 +1098,53 @@ private function record_modify_column( $this->record_change_column( $table_is_temporary, $table_name, $column_name, $column_name, $node ); } + /** + * Record ALTER COLUMN SET/DROP DEFAULT data in the information schema. + * + * @param bool $table_is_temporary Whether the table is temporary. + * @param string $table_name The table name. + * @param string $column_name The column name. + * @param string|null $default_value The default value, or null for DROP DEFAULT. + * @param bool $is_generated Whether the default is an expression. + */ + private function record_alter_column_default( + bool $table_is_temporary, + string $table_name, + string $column_name, + ?string $default_value, + bool $is_generated + ): void { + $columns_table_name = $this->get_table_name( $table_is_temporary, 'columns' ); + $extra = (string) $this->connection->query( + 'SELECT extra FROM ' . $this->connection->quote_identifier( $columns_table_name ) . ' + WHERE table_schema = ? AND table_name = ? AND column_name = ?', + array( self::SAVED_DATABASE_NAME, $table_name, $column_name ) + )->fetchColumn(); + + $extras = array_filter( + preg_split( '/\\s+/', $extra ), + function ( string $item ): bool { + return 'DEFAULT_GENERATED' !== $item; + } + ); + if ( $is_generated ) { + $extras[] = 'DEFAULT_GENERATED'; + } + + $this->update_values( + $columns_table_name, + array( + 'column_default' => $default_value, + 'extra' => implode( ' ', $extras ), + ), + array( + 'table_schema' => self::SAVED_DATABASE_NAME, + 'table_name' => $table_name, + 'column_name' => $column_name, + ) + ); + } + /** * Record DROP COLUMN data in the information schema. * @@ -2099,6 +2258,51 @@ private function get_column_default( WP_Parser_Node $node ): ?string { throw new Exception( 'DEFAULT value of this type is not supported.' ); } + /** + * Extract a DEFAULT value from a node that directly contains a default value. + * + * @param WP_Parser_Node $node The AST node containing a DEFAULT clause. + * @return string|null The default value as stored in information schema. + */ + private function get_default_value_from_node( WP_Parser_Node $node ): ?string { + if ( $node->has_child_token( WP_MySQL_Lexer::NOW_SYMBOL ) ) { + return 'CURRENT_TIMESTAMP'; + } + + $signed_literal = $node->get_first_child_node( 'signedLiteral' ); + if ( $signed_literal ) { + $literal = $signed_literal->get_first_child_node( 'literal' ); + if ( $literal && $literal->has_child_node( 'nullLiteral' ) ) { + return null; + } + + if ( $literal && $literal->has_child_node( 'boolLiteral' ) ) { + $bool_literal = $literal->get_first_child_node( 'boolLiteral' ); + return $bool_literal->has_child_token( WP_MySQL_Lexer::TRUE_SYMBOL ) ? '1' : '0'; + } + + return $this->get_value( $signed_literal ); + } + + $expr_with_parens = $node->get_first_child_node( 'exprWithParentheses' ); + if ( $expr_with_parens ) { + return $this->serialize_mysql_expression( $expr_with_parens ); + } + + throw new Exception( 'DEFAULT value of this type is not supported.' ); + } + + /** + * Check whether a DEFAULT value is generated from an expression. + * + * @param WP_Parser_Node $node The AST node containing a DEFAULT clause. + * @return bool True when the default value is generated. + */ + private function is_default_value_generated( WP_Parser_Node $node ): bool { + return $node->has_child_node( 'exprWithParentheses' ) + || $node->has_child_token( WP_MySQL_Lexer::NOW_SYMBOL ); + } + /** * Extract column nullability from the "columnDefinition" or "fieldDefinition" AST node. * diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-reconstructor.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-reconstructor.php index 103bb8c1..f50f9ee5 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-reconstructor.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-reconstructor.php @@ -59,7 +59,8 @@ public function __construct( * 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(); + $sqlite_schema_objects = $this->get_sqlite_schema_objects(); + $sqlite_tables = array_keys( $sqlite_schema_objects ); $information_schema_tables = $this->get_information_schema_table_names(); // In WordPress, use "wp_get_db_schema()" to reconstruct WordPress tables. @@ -73,7 +74,7 @@ public function ensure_correct_information_schema(): void { $ast = $wp_tables[ $table ]; } else { // Other table (a WordPress plugin or unrelated to WordPress). - $sql = $this->generate_create_table_statement( $table ); + $sql = $this->generate_create_table_statement( $table, $sqlite_schema_objects[ $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.' ); @@ -88,6 +89,9 @@ public function ensure_correct_information_schema(): void { $this->record_drop_table( $table ); $this->schema_builder->record_create_table( $ast ); + if ( 'view' === $sqlite_schema_objects[ $table ] ) { + $this->record_view_type( $table ); + } } } @@ -123,12 +127,12 @@ private function record_drop_table( string $table_name ): void { * * @return string[] The names of tables in the SQLite database. */ - private function get_sqlite_table_names(): array { - return $this->driver->execute_sqlite_query( + private function get_sqlite_schema_objects(): array { + $rows = $this->driver->execute_sqlite_query( " - SELECT name + SELECT name, type FROM sqlite_master - WHERE type = 'table' + WHERE type IN ('table', 'view') AND name != ? AND name NOT LIKE ? ESCAPE '\' AND name NOT LIKE ? ESCAPE '\' @@ -139,7 +143,25 @@ private function get_sqlite_table_names(): array { 'sqlite\_%', str_replace( '_', '\_', WP_PDO_MySQL_On_SQLite::RESERVED_PREFIX ) . '%', ) - )->fetchAll( PDO::FETCH_COLUMN ); + )->fetchAll( PDO::FETCH_ASSOC ); + + return array_column( $rows, 'type', 'name' ); + } + + /** + * Mark an information schema table record as a view. + * + * @param string $view_name The view name. + */ + private function record_view_type( string $view_name ): void { + $tables_table = $this->schema_builder->get_table_name( false, 'tables' ); + $this->driver->execute_sqlite_query( + sprintf( + "UPDATE %s SET table_type = 'VIEW' WHERE table_schema = ? AND table_name = ?", + $this->connection->quote_identifier( $tables_table ) + ), + array( WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME, $view_name ) + ); } /** @@ -255,7 +277,7 @@ private function get_wp_create_table_statements(): array { * @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 { + private function generate_create_table_statement( string $table_name, string $object_type = 'table' ): string { // Columns. $columns = $this->driver->execute_sqlite_query( sprintf( @@ -264,6 +286,25 @@ private function generate_create_table_statement( string $table_name ): string { ) )->fetchAll( PDO::FETCH_ASSOC ); + if ( 'view' === $object_type && 0 === count( $columns ) ) { + $stmt = $this->driver->execute_sqlite_query( + sprintf( + 'SELECT * FROM %s LIMIT 0', + $this->connection->quote_identifier( $table_name ) + ) + ); + for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { + $meta = $stmt->getColumnMeta( $i ); + $columns[] = array( + 'name' => $meta['name'], + 'type' => $meta['sqlite:decl_type'] ?? 'TEXT', + 'notnull' => 0, + 'dflt_value' => null, + 'pk' => 0, + ); + } + } + $definitions = array(); $column_types = array(); foreach ( $columns as $column ) { diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 7abd5add..4b3407f7 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -318,6 +318,23 @@ public function dateformat( $date, $format ) { return gmdate( $format, $time ); } + /** + * Convert a MySQL date/time function argument to a Unix timestamp. + * + * MySQL date/time extraction functions return NULL when the date/time + * argument is NULL. Avoid passing NULL to strtotime(), which is deprecated + * in PHP 8.1+ and is also not the MySQL behavior these functions emulate. + * + * @param mixed $field Date/time value. + * @return int|false|null Unix timestamp, false for strtotime() failures, or null. + */ + private function strtotime_or_null( $field ) { + if ( null === $field ) { + return null; + } + return strtotime( $field ); + } + /** * Method to extract the month value from the date. * @@ -326,6 +343,11 @@ public function dateformat( $date, $format ) { * @return string Representing the number of the month between 1 and 12. */ public function month( $field ) { + $time = $this->strtotime_or_null( $field ); + if ( null === $time ) { + return null; + } + /* * MySQL returns 0 for MONTH('0000-00-00') and for dates with * zero month parts like '2020-00-15'. PHP's strtotime() can't @@ -340,7 +362,7 @@ public function month( $field ) { * n - Numeric representation of a month, without leading zeros. * 1 through 12 */ - return intval( gmdate( 'n', strtotime( $field ) ) ); + return intval( gmdate( 'n', $time ) ); } /** @@ -351,6 +373,11 @@ public function month( $field ) { * @return string Representing the number of the year. */ public function year( $field ) { + $time = $this->strtotime_or_null( $field ); + if ( null === $time ) { + return null; + } + /* * MySQL returns 0 for YEAR('0000-00-00'). PHP's strtotime() * can't parse zero dates, so we extract the year directly. @@ -363,7 +390,7 @@ public function year( $field ) { * * Y - A full numeric representation of a year, 4 digits. */ - return intval( gmdate( 'Y', strtotime( $field ) ) ); + return intval( gmdate( 'Y', $time ) ); } /** @@ -374,6 +401,11 @@ public function year( $field ) { * @return string Representing the number of the day of the month from 1 and 31. */ public function day( $field ) { + $time = $this->strtotime_or_null( $field ); + if ( null === $time ) { + return null; + } + /* * MySQL returns 0 for DAY('0000-00-00') and for dates with * zero day parts like '2020-01-00'. PHP's strtotime() can't @@ -388,7 +420,7 @@ public function day( $field ) { * j - Day of the month without leading zeros. * 1 to 31. */ - return intval( gmdate( 'j', strtotime( $field ) ) ); + return intval( gmdate( 'j', $time ) ); } /** @@ -401,12 +433,17 @@ public function day( $field ) { * @return number Unsigned integer */ public function second( $field ) { + $time = $this->strtotime_or_null( $field ); + if ( null === $time ) { + return null; + } + /* * From https://www.php.net/manual/en/datetime.format.php: * * s - Seconds, with leading zeros (00 to 59) */ - return intval( gmdate( 's', strtotime( $field ) ) ); + return intval( gmdate( 's', $time ) ); } /** @@ -417,13 +454,18 @@ public function second( $field ) { * @return int */ public function minute( $field ) { + $time = $this->strtotime_or_null( $field ); + if ( null === $time ) { + return null; + } + /* * From https://www.php.net/manual/en/datetime.format.php: * * i - Minutes with leading zeros. * 00 to 59. */ - return intval( gmdate( 'i', strtotime( $field ) ) ); + return intval( gmdate( 'i', $time ) ); } /** @@ -437,13 +479,18 @@ public function minute( $field ) { * @return int */ public function hour( $time ) { + $time = $this->strtotime_or_null( $time ); + if ( null === $time ) { + return null; + } + /* * 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 ) ) ); + return intval( gmdate( 'H', $time ) ); } /** @@ -477,6 +524,11 @@ public function hour( $time ) { * @param int $mode The mode argument. */ public function week( $field, $mode ) { + $time = $this->strtotime_or_null( $field ); + if ( null === $time ) { + return null; + } + /* * From https://www.php.net/manual/en/datetime.format.php: * @@ -485,7 +537,7 @@ public function week( $field, $mode ) { * * Week 1 is the first week with a Thursday in it. */ - return intval( gmdate( 'W', strtotime( $field ) ) ); + return intval( gmdate( 'W', $time ) ); } /** @@ -506,12 +558,17 @@ public function week( $field, $mode ) { * @return int */ public function weekday( $field ) { + $time = $this->strtotime_or_null( $field ); + if ( null === $time ) { + return null; + } + /* * 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; + return intval( gmdate( 'N', $time ) ) - 1; } /** @@ -524,7 +581,11 @@ public function weekday( $field ) { * @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 ) ) ); + $time = $this->strtotime_or_null( $field ); + if ( null === $time ) { + return null; + } + return intval( gmdate( 'j', $time ) ); } /** @@ -538,13 +599,18 @@ public function dayofmonth( $field ) { * @return int Returns the weekday index for date (1 = Sunday, 2 = Monday, …, 7 = Saturday). */ public function dayofweek( $field ) { + $time = $this->strtotime_or_null( $field ); + if ( null === $time ) { + return null; + } + /** * 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; + return intval( gmdate( 'w', $time ) ) + 1; } /** @@ -557,7 +623,11 @@ public function dayofweek( $field ) { * @return string formatted as '0000-00-00'. */ public function date( $date ) { - return gmdate( 'Y-m-d', strtotime( $date ) ); + $time = $this->strtotime_or_null( $date ); + if ( null === $time ) { + return null; + } + return gmdate( 'Y-m-d', $time ); } /** @@ -597,6 +667,16 @@ public function _if( $expression, $truthy, $falsy ) { * @return integer 1 if matched, 0 if not matched. */ public function regexp( $pattern, $field ) { + if ( null === $pattern || null === $field ) { + return null; + } + if ( $pattern instanceof WP_PHP_Engine_Blob ) { + $pattern = $pattern->bytes; + } + if ( $field instanceof WP_PHP_Engine_Blob ) { + $field = $field->bytes; + } + /* * If the original query says REGEXP BINARY * the comparison is byte-by-byte and letter casing now diff --git a/packages/mysql-on-sqlite/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index 9ac8a301..916044fd 100644 --- a/packages/mysql-on-sqlite/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -18,10 +18,20 @@ public function __construct( $arg1 = null, $arg2 = null ) { class WP_PDO_MySQL_On_SQLite_PDO_API_Tests extends TestCase { /** @var WP_PDO_MySQL_On_SQLite */ - private $driver; + protected $driver; + + /** + * Create the driver instance to run the tests on. + * + * This can be overridden to run the test suite against other backends, + * such as the pure-PHP database engine (WP_PHP_Engine_PDO). + */ + protected function create_driver(): WP_PDO_MySQL_On_SQLite { + return new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' ); + } public function setUp(): void { - $this->driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' ); + $this->driver = $this->create_driver(); // Run all tests with stringified fetch mode results, so we can use // assertions that are consistent across all tested PHP versions. diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Metadata_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Metadata_Tests.php index 6b6f4b7f..7e46f03f 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Metadata_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Metadata_Tests.php @@ -4,15 +4,25 @@ class WP_SQLite_Driver_Metadata_Tests extends TestCase { /** @var WP_SQLite_Driver */ - private $engine; + protected $engine; /** @var PDO */ - private $sqlite; + protected $sqlite; + + /** + * Create the PDO instance to run the tests on. + * + * This can be overridden to run the test suite against other backends, + * such as the pure-PHP database engine (WP_PHP_Engine_PDO). + */ + protected function create_pdo(): PDO { + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + return new $pdo_class( 'sqlite::memory:' ); + } // Before each test, we create a new database public function setUp(): void { - $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; - $this->sqlite = new $pdo_class( 'sqlite::memory:' ); + $this->sqlite = $this->create_pdo(); $this->engine = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'pdo' => $this->sqlite ) ), 'wp' diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Query_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Query_Tests.php index 2fd5d69d..31f3b4f2 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Query_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Query_Tests.php @@ -88,10 +88,10 @@ */ class WP_SQLite_Driver_Query_Tests extends TestCase { /** @var WP_SQLite_Driver */ - private $engine; + protected $engine; /** @var PDO */ - private $sqlite; + protected $sqlite; /** * Before each test, we create a new volatile database and WordPress tables. @@ -99,13 +99,23 @@ class WP_SQLite_Driver_Query_Tests extends TestCase { * @return void * @throws Exception */ + /** + * Create the PDO instance to run the tests on. + * + * This can be overridden to run the test suite against other backends, + * such as the pure-PHP database engine (WP_PHP_Engine_PDO). + */ + protected function create_pdo(): PDO { + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + return new $pdo_class( 'sqlite::memory:' ); + } + public function setUp(): void { /* This is the DDL for WordPress tables in SQLite syntax. */ global $tables; $queries = explode( ';', $tables ); - $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; - $this->sqlite = new $pdo_class( 'sqlite::memory:' ); + $this->sqlite = $this->create_pdo(); $this->engine = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'pdo' => $this->sqlite ) ), 'wp' diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 18a0424c..618b3eab 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -4,15 +4,25 @@ class WP_SQLite_Driver_Tests extends TestCase { /** @var WP_SQLite_Driver */ - private $engine; + protected $engine; /** @var PDO */ - private $sqlite; + protected $sqlite; + + /** + * Create the PDO instance to run the tests on. + * + * This can be overridden to run the test suite against other backends, + * such as the pure-PHP database engine (WP_PHP_Engine_PDO). + */ + protected function create_pdo(): PDO { + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + return new $pdo_class( 'sqlite::memory:' ); + } // Before each test, we create a new database public function setUp(): void { - $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; - $this->sqlite = new $pdo_class( 'sqlite::memory:' ); + $this->sqlite = $this->create_pdo(); $this->engine = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'pdo' => $this->sqlite ) ), @@ -222,7 +232,98 @@ public function testUpdateWithoutWhereButWithLimit() { $this->assertEquals( '2003-05-27 10:08:48', $result2[0]->option_value ); } + + public function testUpdateMultipleTables(): void { + $this->assertQuery( 'CREATE TABLE _update_left (id INT, value TEXT)' ); + $this->assertQuery( 'CREATE TABLE _update_right (id INT, value TEXT)' ); + $this->assertQuery( "INSERT INTO _update_left VALUES (1, 'left-old'), (2, 'left-keep')" ); + $this->assertQuery( "INSERT INTO _update_right VALUES (1, 'right-old'), (3, 'right-keep')" ); + + $this->assertQuery( + 'UPDATE _update_left AS l, _update_right AS r + SET l.value = r.value, r.value = l.value + WHERE l.id = r.id' + ); + + $this->assertQuery( 'SELECT * FROM _update_left ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'right-old', + ), + (object) array( + 'id' => '2', + 'value' => 'left-keep', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( 'SELECT * FROM _update_right ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'left-old', + ), + (object) array( + 'id' => '3', + 'value' => 'right-keep', + ), + ), + $this->engine->get_query_results() + ); + } + + public function testUpdateMultipleTablesWithOrderByAndLimit(): void { + $this->assertQuery( 'CREATE TABLE _update_left (id INT, value TEXT)' ); + $this->assertQuery( 'CREATE TABLE _update_right (id INT, value TEXT)' ); + $this->assertQuery( "INSERT INTO _update_left VALUES (1, 'left-1'), (2, 'left-2')" ); + $this->assertQuery( "INSERT INTO _update_right VALUES (1, 'right-1'), (2, 'right-2')" ); + + $this->assertQuery( + 'UPDATE _update_left AS l, _update_right AS r + SET l.value = r.value, r.value = l.value + WHERE l.id = r.id + ORDER BY l.id DESC + LIMIT 1' + ); + + $this->assertQuery( 'SELECT * FROM _update_left ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'left-1', + ), + (object) array( + 'id' => '2', + 'value' => 'right-2', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( 'SELECT * FROM _update_right ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'right-1', + ), + (object) array( + 'id' => '2', + 'value' => 'left-2', + ), + ), + $this->engine->get_query_results() + ); + } + + public function testDeleteWithLimit() { + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', '2003-05-27 00:00:45')" ); @@ -852,6 +953,258 @@ public function testShowTablesLike() { ); } + public function testShowTablesLikeTemporaryTable() { + $this->assertQuery( + "CREATE TEMPORARY TABLE _tmp_table ( + ID INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, + option_name TEXT NOT NULL default '', + option_value TEXT NOT NULL default '' + );" + ); + + $this->assertQuery( + "SHOW TABLES LIKE '_tmp_table';" + ); + $this->assertEquals( + array( + (object) array( + 'Tables_in_wp' => '_tmp_table', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( + "SHOW FULL TABLES LIKE '_tmp_table';" + ); + $this->assertEquals( + array( + (object) array( + 'Tables_in_wp' => '_tmp_table', + 'Table_type' => 'BASE TABLE', + ), + ), + $this->engine->get_query_results() + ); + } + + public function testCreateTableAsSelectUpdatesInformationSchema(): void { + $this->assertQuery( "INSERT INTO _options (option_name, option_value) VALUES ('ctas-name', 'ctas-value')" ); + + $this->assertQuery( + "CREATE TABLE _ctas_table AS + SELECT option_name, option_value + FROM _options + WHERE option_name = 'ctas-name'" + ); + + $this->assertQuery( "SHOW TABLES LIKE '_ctas_table'" ); + $this->assertEquals( + array( + (object) array( + 'Tables_in_wp' => '_ctas_table', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( 'DESCRIBE _ctas_table' ); + $this->assertEquals( + array( 'option_name', 'option_value' ), + array_column( $this->engine->get_query_results(), 'Field' ) + ); + + $this->assertQuery( 'SELECT * FROM _ctas_table' ); + $this->assertEquals( + array( + (object) array( + 'option_name' => 'ctas-name', + 'option_value' => 'ctas-value', + ), + ), + $this->engine->get_query_results() + ); + } + + public function testCreateViewUpdatesInformationSchema(): void { + $this->assertQuery( 'CREATE VIEW _options_view AS SELECT option_name FROM _options' ); + + $this->assertQuery( "SHOW FULL TABLES LIKE '_options_view'" ); + $this->assertEquals( + array( + (object) array( + 'Tables_in_wp' => '_options_view', + 'Table_type' => 'VIEW', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( 'DESCRIBE _options_view' ); + $this->assertEquals( + array( 'option_name' ), + array_column( $this->engine->get_query_results(), 'Field' ) + ); + + $this->assertQuery( 'DROP VIEW _options_view' ); + $this->assertQuery( "SHOW TABLES LIKE '_options_view'" ); + $this->assertEquals( array(), $this->engine->get_query_results() ); + } + + public function testCreateTriggerWithSemicolonBody(): void { + $this->assertQuery( "INSERT INTO _dates (ID, option_name, option_value) VALUES (1, 'before-trigger', '2026-06-12 00:00:00')" ); + $this->assertQuery( + 'CREATE TRIGGER _options_after_update + AFTER UPDATE ON _options + FOR EACH ROW + BEGIN + UPDATE _dates SET option_name = NEW.option_name WHERE ID = 1; + END' + ); + + $this->assertQuery( "INSERT INTO _options (ID, option_name, option_value) VALUES (10, 'triggered', '')" ); + $this->assertQuery( "UPDATE _options SET option_name = 'triggered-new' WHERE ID = 10" ); + $this->assertQuery( 'SELECT option_name FROM _dates WHERE ID = 1' ); + $this->assertEquals( 'triggered-new', $this->engine->get_query_results()[0]->option_name ); + } + + public function testCreateTriggerUpdatesInformationSchema(): void { + $this->assertQuery( + 'CREATE TRIGGER _options_after_insert + AFTER INSERT ON _options + FOR EACH ROW + BEGIN + UPDATE _dates SET option_name = NEW.option_name WHERE ID = 1; + END' + ); + + $this->assertQuery( + "SELECT + trigger_name AS trigger_name, + event_manipulation AS event_manipulation, + event_object_table AS event_object_table, + action_timing AS action_timing + FROM information_schema.triggers + WHERE trigger_name = '_options_after_insert'" + ); + $this->assertEquals( + array( + (object) array( + 'trigger_name' => '_options_after_insert', + 'event_manipulation' => 'INSERT', + 'event_object_table' => '_options', + 'action_timing' => 'AFTER', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( 'DROP TRIGGER _options_after_insert' ); + $this->assertQuery( "SELECT trigger_name FROM information_schema.triggers WHERE trigger_name = '_options_after_insert'" ); + $this->assertEquals( array(), $this->engine->get_query_results() ); + } + + public function testCreateAndDropDatabaseMetadata(): void { + $this->assertQuery( 'CREATE DATABASE IF NOT EXISTS other CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' ); + $this->assertQuery( + "SELECT + schema_name AS schema_name, + default_character_set_name AS default_character_set_name, + default_collation_name AS default_collation_name + FROM information_schema.schemata + WHERE schema_name = 'other'" + ); + $this->assertEquals( + array( + (object) array( + 'schema_name' => 'other', + 'default_character_set_name' => 'utf8mb4', + 'default_collation_name' => 'utf8mb4_unicode_ci', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( 'USE other' ); + $this->assertQuery( 'USE wp' ); + $this->assertQuery( 'DROP DATABASE other' ); + $this->assertQuery( "SELECT schema_name AS schema_name FROM information_schema.schemata WHERE schema_name = 'other'" ); + $this->assertEquals( array(), $this->engine->get_query_results() ); + } + + public function testRoutineAndEventMetadata(): void { + $this->assertQuery( 'CREATE PROCEDURE _test_procedure() BEGIN SELECT 1; END' ); + $this->assertQuery( 'CREATE FUNCTION _test_function() RETURNS INT RETURN 1' ); + $this->assertQuery( 'CREATE EVENT _test_event ON SCHEDULE EVERY 1 DAY DO SELECT 1' ); + + $this->assertQuery( + "SELECT + routine_name AS routine_name, + routine_type AS routine_type, + data_type AS data_type + FROM information_schema.routines + WHERE routine_name IN ('_test_procedure', '_test_function') + ORDER BY routine_name" + ); + $this->assertEquals( + array( + (object) array( + 'routine_name' => '_test_function', + 'routine_type' => 'FUNCTION', + 'data_type' => 'int', + ), + (object) array( + 'routine_name' => '_test_procedure', + 'routine_type' => 'PROCEDURE', + 'data_type' => '', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( + "SELECT + event_name AS event_name, + event_type AS event_type + FROM information_schema.events + WHERE event_name = '_test_event'" + ); + $this->assertEquals( + array( + (object) array( + 'event_name' => '_test_event', + 'event_type' => 'RECURRING', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( 'CALL _test_procedure()' ); + $this->assertQuery( 'DROP FUNCTION _test_function' ); + $this->assertQuery( 'DROP PROCEDURE _test_procedure' ); + $this->assertQuery( 'DROP EVENT _test_event' ); + $this->assertQuery( 'SELECT routine_name FROM information_schema.routines' ); + $this->assertEquals( array(), $this->engine->get_query_results() ); + $this->assertQuery( 'SELECT event_name FROM information_schema.events' ); + $this->assertEquals( array(), $this->engine->get_query_results() ); + } + + public function testMultiQueryReturnsLastResult(): void { + $this->assertQuery( + "INSERT INTO _options (option_name, option_value) VALUES ('multi-1', 'first'); + INSERT INTO _options (option_name, option_value) VALUES ('multi-2', 'second'); + SELECT option_value FROM _options WHERE option_name LIKE 'multi-%' ORDER BY option_name" + ); + + $this->assertEquals( + array( + (object) array( 'option_value' => 'first' ), + (object) array( 'option_value' => 'second' ), + ), + $this->engine->get_query_results() + ); + } + public function testShowTableStatusFrom() { // Created in setUp() function $this->assertQuery( 'DROP TABLE _options' ); @@ -1304,6 +1657,58 @@ public function testAlterTableAddAndDropColumn() { ); } + public function testAlterTableAddParenthesizedColumns(): void { + $this->assertQuery( 'CREATE TABLE _tmp_table (id INT)' ); + + $this->assertQuery( 'ALTER TABLE _tmp_table ADD COLUMN (a INT DEFAULT 1, b TEXT DEFAULT "bee")' ); + + $this->assertQuery( 'DESCRIBE _tmp_table' ); + $this->assertEquals( + array( + 'id' => null, + 'a' => '1', + 'b' => 'bee', + ), + array_column( $this->engine->get_query_results(), 'Default', 'Field' ) + ); + + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (10)' ); + $this->assertQuery( 'SELECT * FROM _tmp_table' ); + $this->assertEquals( + array( + (object) array( + 'id' => '10', + 'a' => '1', + 'b' => 'bee', + ), + ), + $this->engine->get_query_results() + ); + } + + public function testAlterTableAlterColumnDefault(): void { + $this->assertQuery( 'CREATE TABLE _tmp_table (id INT, a INT DEFAULT 1, b TEXT)' ); + $this->assertQuery( 'ALTER TABLE _tmp_table ALTER COLUMN a SET DEFAULT (1 + 2)' ); + $this->assertQuery( "ALTER TABLE _tmp_table ALTER COLUMN b SET DEFAULT 'bee'" ); + + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (1)' ); + $this->assertQuery( 'SELECT * FROM _tmp_table' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'a' => '3', + 'b' => 'bee', + ), + ), + $this->engine->get_query_results() + ); + + $this->assertQuery( 'ALTER TABLE _tmp_table ALTER COLUMN a DROP DEFAULT' ); + $this->assertQuery( 'DESCRIBE _tmp_table' ); + $this->assertNull( array_column( $this->engine->get_query_results(), 'Default', 'Field' )['a'] ); + } + public function testAlterTableAddNotNullVarcharColumn() { $result = $this->assertQuery( "CREATE TABLE _tmp_table ( @@ -5780,10 +6185,16 @@ public function testSessionSqlModes(): void { $this->assertSame( 'ONLY_FULL_GROUP_BY', $result[0]->{'@@session.SQL_mode'} ); } - public function testMultiQueryNotSupported(): void { - $this->expectException( WP_SQLite_Driver_Exception::class ); - $this->expectExceptionMessage( 'Multi-query is not supported.' ); - $this->assertQuery( 'SELECT 1; SELECT 2' ); + public function testMultiQuerySupported(): void { + $result = $this->assertQuery( 'SELECT 1 AS value; SELECT 2 AS value' ); + $this->assertEquals( + array( + (object) array( + 'value' => '2', + ), + ), + $result + ); } public function testCreateTableDuplicateTableName(): void { @@ -10865,9 +11276,34 @@ public function testWriteWithUsageOfInformationSchemaTables(): void { $result ); - // TODO: UPDATE with JOIN on information schema is not supported yet. + // UPDATE with JOIN on information schema. + $this->assertQuery( + 'UPDATE t + JOIN information_schema.tables it ON t.value = it.table_name + SET t.value = it.table_schema + WHERE t.id = 1' + ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'wp', + ), + (object) array( + 'id' => '2', + 'value' => 't', + ), + (object) array( + 'id' => '3', + 'value' => 't', + ), + ), + $result + ); // DELETE with JOIN on information schema. + $this->assertQuery( 'UPDATE t SET value = "t" WHERE id = 1' ); $this->assertQuery( 'UPDATE t SET value = "other" WHERE id > 1' ); $this->assertQuery( 'DELETE t FROM t JOIN information_schema.tables it ON t.value = it.table_name' ); $result = $this->assertQuery( 'SELECT * FROM t' ); @@ -10886,6 +11322,54 @@ public function testWriteWithUsageOfInformationSchemaTables(): void { ); } + public function testDeleteWithJoinTargetAlias(): void { + $this->assertQuery( + 'CREATE TABLE _order_items ( + order_item_id INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, + order_id INTEGER NOT NULL, + order_item_type TEXT NOT NULL + )' + ); + $this->assertQuery( + 'CREATE TABLE _order_itemmeta ( + meta_id INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, + order_item_id INTEGER NOT NULL, + meta_key TEXT NOT NULL + )' + ); + $this->assertQuery( + "INSERT INTO _order_items (order_id, order_item_type) VALUES + (13, 'fee'), + (13, 'line_item'), + (14, 'fee')" + ); + $this->assertQuery( + "INSERT INTO _order_itemmeta (order_item_id, meta_key) VALUES + (1, 'delete_1'), + (1, 'delete_2'), + (2, 'keep_line_item'), + (3, 'keep_other_order')" + ); + + $this->assertQuery( + "DELETE itemmeta + FROM _order_itemmeta AS itemmeta + INNER JOIN _order_items AS items + WHERE itemmeta.order_item_id = items.order_item_id + AND items.order_id = 13 + AND items.order_item_type = 'fee'" + ); + + $this->assertQuery( 'SELECT meta_key FROM _order_itemmeta ORDER BY meta_id' ); + $this->assertEquals( + array( + (object) array( 'meta_key' => 'keep_line_item' ), + (object) array( 'meta_key' => 'keep_other_order' ), + ), + $this->engine->get_query_results() + ); + } + public function testNonEmptyColumnMeta(): void { $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); $this->assertQuery( 'INSERT INTO t VALUES (1)' ); diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php index af14aecf..684f7f2d 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php @@ -12,13 +12,13 @@ class WP_SQLite_Information_Schema_Reconstructor_Tests extends TestCase { )'; /** @var WP_SQLite_Driver */ - private $engine; + protected $engine; /** @var WP_SQLite_Information_Schema_Reconstructor */ - private $reconstructor; + protected $reconstructor; /** @var PDO */ - private $sqlite; + protected $sqlite; public static function setUpBeforeClass(): void { // Mock symbols that are used for WordPress table reconstruction. @@ -39,10 +39,20 @@ function wp_get_db_schema() { } } + /** + * Create the PDO instance to run the tests on. + * + * This can be overridden to run the test suite against other backends, + * such as the pure-PHP database engine (WP_PHP_Engine_PDO). + */ + protected function create_pdo(): PDO { + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + return new $pdo_class( 'sqlite::memory:' ); + } + // Before each test, we create a new database public function setUp(): void { - $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; - $this->sqlite = new $pdo_class( 'sqlite::memory:' ); + $this->sqlite = $this->create_pdo(); $this->engine = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'pdo' => $this->sqlite ) ), 'wp' diff --git a/packages/mysql-on-sqlite/tests/bootstrap.php b/packages/mysql-on-sqlite/tests/bootstrap.php index 3afa3b9d..ec446492 100644 --- a/packages/mysql-on-sqlite/tests/bootstrap.php +++ b/packages/mysql-on-sqlite/tests/bootstrap.php @@ -2,7 +2,6 @@ require_once __DIR__ . '/wp-sqlite-schema.php'; require_once __DIR__ . '/../src/load.php'; - // When on an older SQLite version, enable unsafe back compatibility. $sqlite_version = ( new PDO( 'sqlite::memory:' ) )->query( 'SELECT SQLITE_VERSION();' )->fetch()[0]; if ( version_compare( $sqlite_version, WP_PDO_MySQL_On_SQLite::MINIMUM_SQLITE_VERSION, '<' ) ) { diff --git a/packages/mysql-on-sqlite/tests/php-engine/WP_PHP_Engine_Driver_Metadata_Tests.php b/packages/mysql-on-sqlite/tests/php-engine/WP_PHP_Engine_Driver_Metadata_Tests.php new file mode 100644 index 00000000..9e7fa6b4 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/php-engine/WP_PHP_Engine_Driver_Metadata_Tests.php @@ -0,0 +1,16 @@ + new WP_PHP_Engine_PDO( 'php-engine::memory:' ) ) + ); + } + + /** + * The PDO C implementation of PDO::FETCH_NAMED produces arrays with + * numeric string keys (e.g. "1"), which cannot be represented in plain + * PHP arrays — PHP always casts them to integers. Compare the keys + * by their string values instead. + * + * @dataProvider data_pdo_fetch_methods + */ + public function test_fetch( $query, $mode, $expected ): void { + if ( PDO::FETCH_NAMED !== $mode ) { + parent::test_fetch( $query, $mode, $expected ); + return; + } + $stmt = $this->driver->query( $query ); + $result = $stmt->fetch( $mode ); + $this->assertSame( + array_map( 'strval', array_keys( $expected ) ), + array_map( 'strval', array_keys( $result ) ) + ); + $this->assertSame( array_values( $expected ), array_values( $result ) ); + } + + /** + * See test_fetch() above for the PDO::FETCH_NAMED key handling. + * + * @dataProvider data_pdo_fetch_methods + */ + public function test_query_with_fetch_mode( $query, $mode, $expected ): void { + if ( PDO::FETCH_NAMED !== $mode ) { + parent::test_query_with_fetch_mode( $query, $mode, $expected ); + return; + } + $stmt = $this->driver->query( $query, $mode ); + $result = $stmt->fetch(); + $this->assertSame( + array_map( 'strval', array_keys( $expected ) ), + array_map( 'strval', array_keys( $result ) ) + ); + $this->assertSame( array_values( $expected ), array_values( $result ) ); + } +} diff --git a/packages/mysql-on-sqlite/tests/php-engine/WP_PHP_Engine_Tests.php b/packages/mysql-on-sqlite/tests/php-engine/WP_PHP_Engine_Tests.php new file mode 100644 index 00000000..0a0800f5 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/php-engine/WP_PHP_Engine_Tests.php @@ -0,0 +1,431 @@ +path = tempnam( sys_get_temp_dir(), 'wp-php-engine-test-' ); + unlink( $this->path ); + } + + public function tearDown(): void { + if ( file_exists( $this->path ) ) { + unlink( $this->path ); + } + } + + private function create_file_pdo(): WP_PHP_Engine_PDO { + return new WP_PHP_Engine_PDO( 'php-engine:' . $this->path ); + } + + public function testDdlOnlyPersistsAcrossReopen(): void { + $pdo = $this->create_file_pdo(); + $pdo->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)' ); + $pdo->exec( 'CREATE INDEX t__name ON t (name)' ); + $pdo->exec( 'CREATE TRIGGER t_trigger AFTER UPDATE ON t FOR EACH ROW BEGIN UPDATE t SET name = NEW.name WHERE rowid = NEW.rowid; END' ); + $pdo->exec( 'CREATE VIEW v AS SELECT name FROM t' ); + unset( $pdo ); + + // A schema-only session must produce a durable database file. + $this->assertFileExists( $this->path ); + + $pdo = $this->create_file_pdo(); + $rows = $pdo->query( "SELECT type, name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name" ) + ->fetchAll( PDO::FETCH_NUM ); + $this->assertSame( + array( + array( 'index', 't__name' ), + array( 'table', 't' ), + array( 'trigger', 't_trigger' ), + array( 'view', 'v' ), + ), + $rows + ); + } + + public function testDataAndAutoincrementPersistAcrossReopen(): void { + $pdo = $this->create_file_pdo(); + $pdo->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)' ); + $pdo->exec( "INSERT INTO t (name) VALUES ('first')" ); + $pdo->exec( 'DELETE FROM t' ); + unset( $pdo ); + + $pdo = $this->create_file_pdo(); + $pdo->exec( "INSERT INTO t (name) VALUES ('second')" ); + + // The AUTOINCREMENT counter continues after the deleted row. + $this->assertSame( '2', $pdo->lastInsertId() ); + $this->assertSame( + array( array( 2, 'second' ) ), + $pdo->query( 'SELECT id, name FROM t' )->fetchAll( PDO::FETCH_NUM ) + ); + } + + public function testRolledBackTransactionIsNotPersisted(): void { + $pdo = $this->create_file_pdo(); + $pdo->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)' ); + $pdo->exec( "INSERT INTO t VALUES (1, 'committed')" ); + + $pdo->beginTransaction(); + $pdo->exec( "INSERT INTO t VALUES (2, 'rolled-back')" ); + $pdo->rollBack(); + unset( $pdo ); + + $pdo = $this->create_file_pdo(); + $this->assertSame( + array( array( 1, 'committed' ) ), + $pdo->query( 'SELECT * FROM t' )->fetchAll( PDO::FETCH_NUM ) + ); + } + + public function testConcurrentConnectionsDoNotLoseUpdates(): void { + // Two connections opened from the same (empty) starting snapshot. + $first = $this->create_file_pdo(); + $second = $this->create_file_pdo(); + + // A write on the first connection... + $first->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)' ); + $first->exec( "INSERT INTO t (name) VALUES ('from-first')" ); + + // ...must be visible to a write on the second connection, which + // started from an empty snapshot (no lost updates). + $second->exec( "INSERT INTO t (name) VALUES ('from-second')" ); + + // Both connections and a fresh one see both rows. + $expected = array( + array( 1, 'from-first' ), + array( 2, 'from-second' ), + ); + $this->assertSame( $expected, $second->query( 'SELECT * FROM t ORDER BY id' )->fetchAll( PDO::FETCH_NUM ) ); + $this->assertSame( $expected, $first->query( 'SELECT * FROM t ORDER BY id' )->fetchAll( PDO::FETCH_NUM ) ); + + $third = $this->create_file_pdo(); + $this->assertSame( $expected, $third->query( 'SELECT * FROM t ORDER BY id' )->fetchAll( PDO::FETCH_NUM ) ); + } + + public function testReadsSeeChangesFromOtherConnections(): void { + $writer = $this->create_file_pdo(); + $reader = $this->create_file_pdo(); + + $writer->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)' ); + $this->assertSame( + array(), + $reader->query( 'SELECT * FROM t' )->fetchAll( PDO::FETCH_NUM ) + ); + + $writer->exec( "INSERT INTO t VALUES (1, 'a')" ); + $this->assertSame( + array( array( 1, 'a' ) ), + $reader->query( 'SELECT * FROM t' )->fetchAll( PDO::FETCH_NUM ) + ); + } + + public function testConcurrentWriteTimesOutWhileTransactionHoldsLock(): void { + $writer = $this->create_file_pdo(); + $writer->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)' ); + + $child = $this->start_child_writer( 0, 'from-child' ); + $this->wait_for_file( $child['ready'] ); + + try { + $writer->beginTransaction(); + $writer->exec( "INSERT INTO t (name) VALUES ('from-writer')" ); + + touch( $child['go'] ); + $result = $this->finish_child_writer( $child, 1 ); + + $this->assertSame( 7, $result['exit_code'], $result['stderr'] ); + $this->assertStringContainsString( 'database is locked', $result['stderr'] ); + } finally { + if ( $writer->inTransaction() ) { + $writer->rollBack(); + } + $this->cleanup_child_writer( $child ); + } + } + + public function testConcurrentWriteWaitsForTransactionWithinTimeout(): void { + $writer = $this->create_file_pdo(); + $writer->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)' ); + + $child = $this->start_child_writer( 2, 'from-child' ); + $this->wait_for_file( $child['ready'] ); + + try { + $writer->beginTransaction(); + $writer->exec( "INSERT INTO t (name) VALUES ('from-writer')" ); + touch( $child['go'] ); + usleep( 200000 ); + $writer->commit(); + + $result = $this->finish_child_writer( $child, 5 ); + $this->assertSame( 0, $result['exit_code'], $result['stderr'] ); + $this->assertSame( + array( + array( 'from-writer' ), + array( 'from-child' ), + ), + $writer->query( 'SELECT name FROM t ORDER BY id' )->fetchAll( PDO::FETCH_NUM ) + ); + } finally { + if ( $writer->inTransaction() ) { + $writer->rollBack(); + } + $this->cleanup_child_writer( $child ); + } + } + + public function testPragmaBusyTimeoutCanBeReadBack(): void { + $pdo = $this->create_file_pdo(); + + $this->assertSame( + array( array( 250 ) ), + $pdo->query( 'PRAGMA busy_timeout = 250' )->fetchAll( PDO::FETCH_NUM ) + ); + $this->assertSame( + array( array( 250 ) ), + $pdo->query( 'PRAGMA busy_timeout' )->fetchAll( PDO::FETCH_NUM ) + ); + } + + public function testRefusesToOpenForeignFiles(): void { + // A real SQLite database file (or any unknown format) must never + // be overwritten by the engine. + $foreign_content = "SQLite format 3\0not really, but the header matters"; + file_put_contents( $this->path, $foreign_content ); + + $exception = null; + try { + $this->create_file_pdo(); + } catch ( PDOException $e ) { + $exception = $e; + } + $this->assertNotNull( $exception ); + $this->assertStringContainsString( 'not a WP_PHP_Engine database', $exception->getMessage() ); + $this->assertSame( $foreign_content, file_get_contents( $this->path ) ); + } + + public function testInsertOnConflictDoNothingAffectsNoRows(): void { + $pdo = new WP_PHP_Engine_PDO( 'php-engine::memory:' ); + $pdo->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT UNIQUE)' ); + + $this->assertSame( 1, $pdo->exec( "INSERT INTO t (name) VALUES ('a') ON CONFLICT(name) DO NOTHING" ) ); + $this->assertSame( 0, $pdo->exec( "INSERT INTO t (name) VALUES ('a') ON CONFLICT(name) DO NOTHING" ) ); + + // An upsert that updates the conflicting row affects one row... + $this->assertSame( 1, $pdo->exec( "INSERT INTO t (name) VALUES ('a') ON CONFLICT(name) DO UPDATE SET name = 'b'" ) ); + // ...but not when its WHERE clause filters the update out. + $this->assertSame( 0, $pdo->exec( "INSERT INTO t (name) VALUES ('b') ON CONFLICT(name) DO UPDATE SET name = 'c' WHERE 1 = 0" ) ); + } + + public function testBindParamBindsByReference(): void { + $pdo = new WP_PHP_Engine_PDO( 'php-engine::memory:' ); + $pdo->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)' ); + + $stmt = $pdo->prepare( 'INSERT INTO t (name) VALUES (?)' ); + $name = 'before'; + $stmt->bindParam( 1, $name ); + + // Like in PDO, the variable is read at execute() time. + $name = 'after'; + $stmt->execute(); + $name = 'again'; + $stmt->execute(); + + $this->assertSame( + array( array( 'after' ), array( 'again' ) ), + $pdo->query( 'SELECT name FROM t ORDER BY id' )->fetchAll( PDO::FETCH_NUM ) + ); + } + + public function testInTableSyntax(): void { + $pdo = new WP_PHP_Engine_PDO( 'php-engine::memory:' ); + $pdo->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY)' ); + $pdo->exec( 'INSERT INTO t VALUES (1), (2)' ); + + $this->assertSame( + array( array( 1, 0 ) ), + $pdo->query( 'SELECT 1 IN t, 3 IN t' )->fetchAll( PDO::FETCH_NUM ) + ); + } + + public function testInTableFunctionSyntax(): void { + $pdo = new WP_PHP_Engine_PDO( 'php-engine::memory:' ); + $pdo->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY)' ); + + $this->assertSame( + array( array( 1, 0 ) ), + $pdo->query( "SELECT 0 IN pragma_table_info('t'), 10 IN pragma_table_info('t')" )->fetchAll( PDO::FETCH_NUM ) + ); + } + + public function testFullDriverStackOnFileBackedEngine(): void { + // The complete MySQL driver stack works on a file-backed engine + // across connections. + $driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'pdo' => $this->create_file_pdo() ) ), + 'wp' + ); + $driver->query( 'CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50))' ); + $driver->query( "INSERT INTO t (name) VALUES ('persisted')" ); + + $reopened = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'pdo' => $this->create_file_pdo() ) ), + 'wp' + ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'name' => 'persisted', + ), + ), + $reopened->query( 'SELECT * FROM t' ) + ); + } + + private function start_child_writer( $timeout, $name ): array { + if ( ! function_exists( 'proc_open' ) ) { + $this->markTestSkipped( 'proc_open() is required for lock contention tests.' ); + } + if ( ! defined( 'PHP_BINARY' ) || ! is_executable( PHP_BINARY ) ) { + $this->markTestSkipped( 'An executable PHP_BINARY is required for lock contention tests.' ); + } + + $script = tempnam( sys_get_temp_dir(), 'wp-php-engine-child-' ); + $ready = tempnam( sys_get_temp_dir(), 'wp-php-engine-ready-' ); + $go = tempnam( sys_get_temp_dir(), 'wp-php-engine-go-' ); + unlink( $ready ); + unlink( $go ); + + $load = dirname( __DIR__, 2 ) . '/src/php-engine/load.php'; + file_put_contents( + $script, + " (float) $argv[2] ) );' . "\n" . + 'file_put_contents( $argv[4], \'ready\' );' . "\n" . + '$deadline = microtime( true ) + 5;' . "\n" . + 'while ( ! file_exists( $argv[5] ) ) {' . "\n" . + ' if ( microtime( true ) >= $deadline ) {' . "\n" . + ' fwrite( STDERR, \'timed out waiting for write signal\' );' . "\n" . + ' exit( 9 );' . "\n" . + ' }' . "\n" . + ' usleep( 10000 );' . "\n" . + '}' . "\n" . + 'try {' . "\n" . + ' $stmt = $pdo->prepare( \'INSERT INTO t (name) VALUES (?)\' );' . "\n" . + ' $stmt->execute( array( $argv[3] ) );' . "\n" . + '} catch ( PDOException $e ) {' . "\n" . + ' fwrite( STDERR, $e->getMessage() );' . "\n" . + ' exit( 7 );' . "\n" . + '}' . "\n" + ); + + $command = escapeshellarg( PHP_BINARY ) . ' ' . + escapeshellarg( $script ) . ' ' . + escapeshellarg( $this->path ) . ' ' . + escapeshellarg( (string) $timeout ) . ' ' . + escapeshellarg( $name ) . ' ' . + escapeshellarg( $ready ) . ' ' . + escapeshellarg( $go ); + $pipes = array(); + $process = proc_open( + $command, + array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ), + $pipes + ); + + if ( ! is_resource( $process ) ) { + unlink( $script ); + $this->fail( 'Unable to start child PHP process.' ); + } + + fclose( $pipes[0] ); + + return array( + 'process' => $process, + 'pipes' => $pipes, + 'script' => $script, + 'ready' => $ready, + 'go' => $go, + 'finished' => false, + ); + } + + private function wait_for_file( $path ): void { + $deadline = microtime( true ) + 5; + while ( ! file_exists( $path ) ) { + if ( microtime( true ) >= $deadline ) { + $this->fail( 'Timed out waiting for child PHP process.' ); + } + usleep( 10000 ); + } + } + + private function finish_child_writer( array &$child, $timeout ): array { + $deadline = microtime( true ) + $timeout; + $exit_code = null; + while ( true ) { + $status = proc_get_status( $child['process'] ); + if ( ! $status['running'] ) { + $exit_code = $status['exitcode']; + break; + } + if ( microtime( true ) >= $deadline ) { + proc_terminate( $child['process'] ); + foreach ( array( 1, 2 ) as $index ) { + if ( isset( $child['pipes'][ $index ] ) && is_resource( $child['pipes'][ $index ] ) ) { + fclose( $child['pipes'][ $index ] ); + } + } + proc_close( $child['process'] ); + $child['finished'] = true; + $this->fail( 'Timed out waiting for child writer.' ); + } + usleep( 10000 ); + } + + $stdout = stream_get_contents( $child['pipes'][1] ); + fclose( $child['pipes'][1] ); + $stderr = stream_get_contents( $child['pipes'][2] ); + fclose( $child['pipes'][2] ); + $close_code = proc_close( $child['process'] ); + $exit_code = -1 === $exit_code ? $close_code : $exit_code; + $child['finished'] = true; + + return array( + 'exit_code' => $exit_code, + 'stdout' => $stdout, + 'stderr' => $stderr, + ); + } + + private function cleanup_child_writer( array $child ): void { + if ( empty( $child['finished'] ) && is_resource( $child['process'] ) ) { + proc_terminate( $child['process'] ); + foreach ( array( 1, 2 ) as $index ) { + if ( isset( $child['pipes'][ $index ] ) && is_resource( $child['pipes'][ $index ] ) ) { + fclose( $child['pipes'][ $index ] ); + } + } + proc_close( $child['process'] ); + } + foreach ( array( 'script', 'ready', 'go' ) as $key ) { + if ( isset( $child[ $key ] ) && file_exists( $child[ $key ] ) ) { + unlink( $child[ $key ] ); + } + } + } +} diff --git a/packages/mysql-on-sqlite/tests/tools/php-engine-differential.php b/packages/mysql-on-sqlite/tests/tools/php-engine-differential.php new file mode 100644 index 00000000..84d37618 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/tools/php-engine-differential.php @@ -0,0 +1,246 @@ + [--limit=N] [--max-diffs=N] [--start-session=N] + * + * The corpus is a JSONL file of [sql, params] pairs, as captured by the + * SQLITE_CAPTURE_FILE instrumentation. The corpus is split into sessions + * at each "PRAGMA foreign_keys = ON" statement (emitted once per driver + * initialization), and each session runs on a fresh in-memory database. + */ + +// phpcs:ignoreFile + +$root = dirname( dirname( __DIR__ ) ); +require_once dirname( __DIR__, 2 ) . '/src/php-engine/load.php'; +require_once $root . '/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php'; + +$corpus_file = null; +$limit = PHP_INT_MAX; +$max_diffs = 20; +$start_session = 0; +foreach ( array_slice( $argv, 1 ) as $arg ) { + if ( preg_match( '/^--limit=(\d+)$/', $arg, $m ) ) { + $limit = (int) $m[1]; + } elseif ( preg_match( '/^--max-diffs=(\d+)$/', $arg, $m ) ) { + $max_diffs = (int) $m[1]; + } elseif ( preg_match( '/^--start-session=(\d+)$/', $arg, $m ) ) { + $start_session = (int) $m[1]; + } else { + $corpus_file = $arg; + } +} +if ( null === $corpus_file || ! file_exists( $corpus_file ) ) { + fwrite( STDERR, "Corpus file not found.\n" ); + exit( 1 ); +} + +/** + * Run one query against a PDO instance, capturing the outcome. + */ +function run_query( $pdo, $sql, $params ) { + try { + $stmt = $pdo->prepare( $sql ); + $stmt->execute( $params ); + $rows = $stmt->fetchAll( PDO::FETCH_NUM ); + // Normalize values to strings for comparison. + $normalized = array(); + foreach ( $rows as $row ) { + $out = array(); + foreach ( $row as $value ) { + if ( null === $value ) { + $out[] = null; + } elseif ( is_float( $value ) ) { + $out[] = 'f:' . sprintf( '%.10g', $value ); + } else { + $out[] = (string) $value; + } + } + $normalized[] = $out; + } + // Column names matter for the driver's result handling. + $cols = array(); + for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { + $meta = $stmt->getColumnMeta( $i ); + $cols[] = $meta ? $meta['name'] : '?'; + } + return array( + 'ok' => true, + 'rows' => $normalized, + 'cols' => $cols, + 'last' => $pdo->lastInsertId(), + ); + } catch ( Exception $e ) { + $message = $e->getMessage(); + return array( + 'ok' => false, + 'error' => $message, + ); + } +} + +/** + * Compare two result row sets, tolerating a small wall-clock drift in + * current-timestamp values (the two engines run a moment apart). + */ +function rows_equal( $a_rows, $b_rows ) { + if ( count( $a_rows ) !== count( $b_rows ) ) { + return false; + } + foreach ( $a_rows as $i => $a_row ) { + $b_row = $b_rows[ $i ]; + if ( count( $a_row ) !== count( $b_row ) ) { + return false; + } + foreach ( $a_row as $j => $a_value ) { + $b_value = $b_row[ $j ]; + if ( $a_value === $b_value ) { + continue; + } + if ( is_string( $a_value ) && is_string( $b_value ) + && preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $a_value ) + && preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $b_value ) + && abs( strtotime( $a_value ) - strtotime( $b_value ) ) <= 2 ) { + continue; + } + return false; + } + } + return true; +} + +$lines = file( $corpus_file ); +$sessions = array(); +$current = array(); +foreach ( $lines as $line ) { + $decoded = json_decode( $line, true ); + if ( ! is_array( $decoded ) ) { + continue; + } + if ( 'PRAGMA foreign_keys = ON' === $decoded[0] && count( $current ) > 0 ) { + $sessions[] = $current; + $current = array(); + } + $current[] = $decoded; +} +if ( count( $current ) > 0 ) { + $sessions[] = $current; +} + +printf( "Corpus: %d queries in %d sessions.\n", count( $lines ), count( $sessions ) ); + +$diffs = 0; +$ran = 0; +$ordered_diff = 0; +$start_time = microtime( true ); + +foreach ( $sessions as $session_index => $session ) { + if ( $session_index < $start_session ) { + continue; + } + if ( $ran >= $limit || $diffs >= $max_diffs ) { + break; + } + + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $sqlite = new $pdo_class( 'sqlite::memory:' ); + $sqlite->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $engine = new WP_PHP_Engine_PDO( 'php-engine::memory:' ); + + WP_SQLite_PDO_User_Defined_Functions::register_for( $sqlite ); + WP_SQLite_PDO_User_Defined_Functions::register_for( $engine ); + + foreach ( $session as $query_index => $pair ) { + if ( $ran >= $limit || $diffs >= $max_diffs ) { + break; + } + list( $sql, $params ) = $pair; + + // Skip nondeterministic-by-design queries. + if ( preg_match( '/\b(RANDOM|RAND|RANDOMBLOB)\b/i', $sql ) ) { + continue; + } + + $a = run_query( $sqlite, $sql, $params ); + $b = run_query( $engine, $sql, $params ); + $ran += 1; + + $mismatch = null; + if ( $a['ok'] !== $b['ok'] ) { + $mismatch = sprintf( + "OUTCOME: sqlite=%s engine=%s", + $a['ok'] ? 'ok' : $a['error'], + $b['ok'] ? 'ok' : $b['error'] + ); + } elseif ( ! $a['ok'] ) { + if ( $a['error'] !== $b['error'] ) { + $mismatch = sprintf( "ERROR TEXT:\n sqlite: %s\n engine: %s", $a['error'], $b['error'] ); + } + } else { + if ( $a['cols'] !== $b['cols'] ) { + $mismatch = sprintf( + "COLUMNS:\n sqlite: %s\n engine: %s", + json_encode( $a['cols'] ), + json_encode( $b['cols'] ) + ); + } elseif ( ! rows_equal( $a['rows'], $b['rows'] ) ) { + // Allow row order differences for queries without ORDER BY. + $a_sorted = $a['rows']; + $b_sorted = $b['rows']; + $sorter = function ( $x, $y ) { + return strcmp( json_encode( $x ), json_encode( $y ) ); + }; + usort( $a_sorted, $sorter ); + usort( $b_sorted, $sorter ); + if ( $a_sorted === $b_sorted && ! preg_match( '/ORDER\s+BY/i', $sql ) ) { + $ordered_diff += 1; + } else { + $max = max( count( $a['rows'] ), count( $b['rows'] ) ); + $head = array(); + for ( $i = 0; $i < min( $max, 5 ); $i++ ) { + $head[] = sprintf( + " [%d] sqlite=%s engine=%s", + $i, + json_encode( isset( $a['rows'][ $i ] ) ? $a['rows'][ $i ] : null ), + json_encode( isset( $b['rows'][ $i ] ) ? $b['rows'][ $i ] : null ) + ); + } + $mismatch = sprintf( + "ROWS (%d vs %d):\n%s", + count( $a['rows'] ), + count( $b['rows'] ), + implode( "\n", $head ) + ); + } + } + } + + if ( null !== $mismatch ) { + $diffs += 1; + printf( + "\n=== DIFF #%d (session %d, query %d) ===\nSQL: %s\nPARAMS: %s\n%s\n", + $diffs, + $session_index, + $query_index, + strlen( $sql ) > 600 ? substr( $sql, 0, 600 ) . '…' : $sql, + json_encode( $params ), + $mismatch + ); + } + } +} + +printf( + "\nDone: %d queries compared, %d diffs, %d order-only diffs, %.1fs.\n", + $ran, + $diffs, + $ordered_diff, + microtime( true ) - $start_time +); +exit( $diffs > 0 ? 1 : 0 ); diff --git a/packages/plugin-sqlite-database-integration/activate.php b/packages/plugin-sqlite-database-integration/activate.php index 1001914a..ac323d5d 100644 --- a/packages/plugin-sqlite-database-integration/activate.php +++ b/packages/plugin-sqlite-database-integration/activate.php @@ -75,8 +75,9 @@ function ( $result ) { * When the plugin gets merged in wp-core, this is not to be ported. */ function sqlite_plugin_copy_db_file() { - // Bail early if the PDO SQLite extension is not loaded. - if ( ! extension_loaded( 'pdo_sqlite' ) ) { + // Bail early if the PDO extension is not loaded. Without the PDO SQLite + // driver, the plugin falls back to the bundled pure-PHP database engine. + if ( ! extension_loaded( 'pdo' ) ) { return; } diff --git a/packages/plugin-sqlite-database-integration/admin-notices.php b/packages/plugin-sqlite-database-integration/admin-notices.php index a455cc86..e88b4489 100644 --- a/packages/plugin-sqlite-database-integration/admin-notices.php +++ b/packages/plugin-sqlite-database-integration/admin-notices.php @@ -19,15 +19,23 @@ function sqlite_plugin_admin_notice() { return; } - // If PDO SQLite is not loaded, bail early. - if ( ! extension_loaded( 'pdo_sqlite' ) ) { + // If PDO is not loaded at all, bail early. + if ( ! extension_loaded( 'pdo' ) ) { printf( '

%s

', - esc_html__( 'The SQLite Integration plugin is active, but the PDO SQLite extension is missing from your server. Please make sure that PDO SQLite is enabled in your PHP installation.', 'sqlite-database-integration' ) + esc_html__( 'The SQLite Integration plugin is active, but the PDO extension is missing from your server. Please make sure that PDO is enabled in your PHP installation.', 'sqlite-database-integration' ) ); return; } + // Without the PDO SQLite driver, the bundled pure-PHP database engine is used. + if ( ! extension_loaded( 'pdo_sqlite' ) ) { + printf( + '

%s

', + esc_html__( 'The PDO SQLite driver is missing from your server, so the SQLite Integration plugin is using its bundled pure-PHP database engine. This works, but is slower than SQLite — consider enabling the pdo_sqlite extension.', 'sqlite-database-integration' ) + ); + } + /* * If the SQLITE_DB_DROPIN_VERSION constant is not defined * but there's a db.php file in the wp-content directory, then the module can't be activated. diff --git a/packages/plugin-sqlite-database-integration/admin-page.php b/packages/plugin-sqlite-database-integration/admin-page.php index 82815626..adf7cf5f 100644 --- a/packages/plugin-sqlite-database-integration/admin-page.php +++ b/packages/plugin-sqlite-database-integration/admin-page.php @@ -61,9 +61,9 @@ function sqlite_integration_admin_screen() { ); ?>

- +
-

+

diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php index 03313515..1c951756 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php @@ -34,17 +34,30 @@ } if ( ! extension_loaded( 'pdo_sqlite' ) ) { - wp_die( - new WP_Error( - 'pdo_driver_not_loaded', - sprintf( - '

%1$s

%2$s

', - 'PDO Driver for SQLite is missing', - 'Your PHP installation appears not to have the right PDO drivers loaded. These are required for this version of WordPress and the type of database you have specified.' - ) - ), - 'PDO Driver for SQLite is missing.' - ); + /* + * Without the PDO SQLite driver, fall back to the bundled pure-PHP + * database engine. The fallback requires the new SQLite driver — the + * legacy translator can only work with the pdo_sqlite extension. + */ + if ( defined( 'WP_SQLITE_AST_DRIVER' ) && ! WP_SQLITE_AST_DRIVER ) { + wp_die( + new WP_Error( + 'pdo_driver_not_loaded', + sprintf( + '

%1$s

%2$s

', + 'PDO Driver for SQLite is missing', + 'Your PHP installation appears not to have the right PDO drivers loaded. The legacy SQLite driver requires the PDO SQLite driver. Either enable the pdo_sqlite extension, or remove the "WP_SQLITE_AST_DRIVER" constant to use the bundled pure-PHP database engine.' + ) + ), + 'PDO Driver for SQLite is missing.' + ); + } + + require_once dirname( __DIR__ ) . '/php-engine/load.php'; + + if ( ! defined( 'WP_SQLITE_AST_DRIVER' ) ) { + define( 'WP_SQLITE_AST_DRIVER', true ); + } } require_once __DIR__ . '/../database/load.php'; diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php index 5d73e39e..db435b11 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php @@ -24,21 +24,28 @@ function sqlite_make_db_sqlite() { $table_schemas = wp_get_db_schema(); $queries = explode( ';', $table_schemas ); - try { - $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO - $pdo = new $pdo_class( 'sqlite:' . FQDB, null, null, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); // phpcs:ignore WordPress.DB.RestrictedClasses - } catch ( PDOException $err ) { - $err_data = $err->errorInfo; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $message = 'Database connection error!
'; - $message .= sprintf( 'Error message is: %s', $err_data[2] ); - wp_die( $message, 'Database Error!' ); - } + if ( defined( 'WP_SQLITE_AST_DRIVER' ) && WP_SQLITE_AST_DRIVER ) { + $translator = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'path' => FQDB ) ), + $wpdb->dbname + ); + } else { + try { + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + $pdo = new $pdo_class( 'sqlite:' . FQDB, null, null, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); // phpcs:ignore WordPress.DB.RestrictedClasses + } catch ( PDOException $err ) { + $err_data = $err->errorInfo; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $message = 'Database connection error!
'; + $message .= sprintf( 'Error message is: %s', $err_data[2] ); + wp_die( $message, 'Database Error!' ); + } - $translator = new WP_SQLite_Driver( - new WP_SQLite_Connection( array( 'pdo' => $pdo ) ), - $wpdb->dbname - ); - $query = null; + $translator = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'pdo' => $pdo ) ), + $wpdb->dbname + ); + } + $query = null; try { $translator->begin_transaction();