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(
'',
- 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(
+ '',
+ 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();