This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
@@ -0,0 +1,134 @@
<?php
/**
* Handle the SQLite activation.
*
* @since 1.0.0
* @package wp-sqlite-integration
*/
/**
* Redirect to the plugin's admin screen on activation.
*
* @since 1.0.0
*
* @param string $plugin The plugin basename.
*/
function sqlite_plugin_activation_redirect( $plugin ) {
if ( plugin_basename( SQLITE_MAIN_FILE ) === $plugin ) {
if ( wp_safe_redirect( admin_url( 'options-general.php?page=sqlite-integration' ) ) ) {
exit;
}
}
}
add_action( 'activated_plugin', 'sqlite_plugin_activation_redirect' );
/**
* Check the URL to ensure we're on the plugin page,
* the user has clicked the button to install SQLite,
* and the nonce is valid.
* If the above conditions are met, run the sqlite_plugin_copy_db_file() function,
* and redirect to the install screen.
*
* @since 1.0.0
*/
function sqlite_activation() {
global $current_screen;
if ( isset( $current_screen->base ) && 'settings_page_sqlite-integration' === $current_screen->base ) {
return;
}
if ( isset( $_GET['confirm-install'] ) && wp_verify_nonce( $_GET['_wpnonce'], 'sqlite-install' ) ) {
// Handle upgrading from the performance-lab plugin.
if ( isset( $_GET['upgrade-from-pl'] ) ) {
global $wp_filesystem;
require_once ABSPATH . '/wp-admin/includes/file.php';
// Delete the previous db.php file.
$wp_filesystem->delete( WP_CONTENT_DIR . '/db.php' );
// Deactivate the performance-lab SQLite module.
$pl_option_name = defined( 'PERFLAB_MODULES_SETTING' ) ? PERFLAB_MODULES_SETTING : 'perflab_modules_settings';
$pl_option = get_option( $pl_option_name, array() );
unset( $pl_option['database/sqlite'] );
update_option( $pl_option_name, $pl_option );
}
sqlite_plugin_copy_db_file();
// WordPress will automatically redirect to the install screen here.
wp_redirect( admin_url() );
exit;
}
}
add_action( 'admin_init', 'sqlite_activation' );
// Flush the cache at the last moment before the redirect.
add_filter(
'x_redirect_by',
function ( $result ) {
wp_cache_flush();
return $result;
},
PHP_INT_MAX,
1
);
/**
* Add the db.php file in wp-content.
*
* 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' ) ) {
return;
}
$destination = WP_CONTENT_DIR . '/db.php';
/*
* When an existing "db.php" drop-in is detected, let's check if it's a known
* plugin that we can continue supporting even when we override the drop-in.
*/
$override_db_dropin = false;
if ( file_exists( $destination ) ) {
// Check for the Query Monitor plugin.
// When "QM_DB" exists, it must have been loaded via the "db.php" file.
if ( class_exists( 'QM_DB', false ) ) {
$override_db_dropin = true;
}
if ( $override_db_dropin ) {
require_once ABSPATH . '/wp-admin/includes/file.php';
global $wp_filesystem;
if ( ! $wp_filesystem ) {
WP_Filesystem();
}
$wp_filesystem->delete( $destination );
}
}
// Place database drop-in if not present yet, except in case there is
// another database drop-in present already.
if ( ! defined( 'SQLITE_DB_DROPIN_VERSION' ) && ! file_exists( $destination ) ) {
// Init the filesystem to allow copying the file.
global $wp_filesystem;
require_once ABSPATH . '/wp-admin/includes/file.php';
// Init the filesystem if needed, then copy the file, replacing contents as needed.
if ( ( $wp_filesystem || WP_Filesystem() ) && $wp_filesystem->touch( $destination ) ) {
// Get the db.copy.php file contents, replace placeholders and write it to the destination.
$file_contents = str_replace(
array(
'{SQLITE_IMPLEMENTATION_FOLDER_PATH}',
'{SQLITE_PLUGIN}',
),
array(
__DIR__,
str_replace( WP_PLUGIN_DIR . '/', '', SQLITE_MAIN_FILE ),
),
file_get_contents( __DIR__ . '/db.copy' )
);
$wp_filesystem->put_contents( $destination, $file_contents );
}
}
}
@@ -0,0 +1,76 @@
<?php
/**
* Functions to add admin notices if necessary.
*
* @since 1.0.0
* @package wp-sqlite-integration
*/
/**
* Add admin notices.
*
* When the plugin gets merged in wp-core, this is not to be ported.
*/
function sqlite_plugin_admin_notice() {
// Don't print notices in the plugin's admin screen.
global $current_screen;
if ( isset( $current_screen->base ) && 'settings_page_sqlite-integration' === $current_screen->base ) {
return;
}
// If PDO SQLite is not loaded, bail early.
if ( ! extension_loaded( 'pdo_sqlite' ) ) {
printf(
'<div class="notice notice-error"><p>%s</p></div>',
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' )
);
return;
}
/*
* 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.
* The module should not have been activated in the first place
* (there's a check in the can-load.php file), but this is a fallback check.
*/
if ( file_exists( WP_CONTENT_DIR . '/db.php' ) && ! defined( 'SQLITE_DB_DROPIN_VERSION' ) ) {
printf(
'<div class="notice notice-error"><p>%s</p></div>',
sprintf(
/* translators: 1: SQLITE_DB_DROPIN_VERSION constant, 2: db.php drop-in path */
__( 'The SQLite Integration module is active, but the %1$s constant is missing. It appears you already have another %2$s file present on your site. ', 'sqlite-database-integration' ),
'<code>SQLITE_DB_DROPIN_VERSION</code>',
'<code>' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php</code>'
)
);
return;
}
if ( file_exists( WP_CONTENT_DIR . '/db.php' ) ) {
return;
}
if ( ! wp_is_writable( WP_CONTENT_DIR ) ) {
printf(
'<div class="notice notice-error"><p>%s</p></div>',
esc_html__( 'The SQLite Integration plugin is active, but the wp-content/db.php file is missing and the wp-content directory is not writable. Please ensure the wp-content folder is writable, then deactivate the plugin and try again.', 'sqlite-database-integration' )
);
return;
}
// The dropin db.php is missing.
printf(
'<div class="notice notice-error"><p>%s</p></div>',
sprintf(
/* translators: 1: db.php drop-in path, 2: Admin URL to deactivate the module */
__( 'The SQLite Integration plugin is active, but the %1$s file is missing. Please <a href="%2$s">deactivate the plugin</a> and re-activate it to try again.', 'sqlite-database-integration' ),
'<code>' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php</code>',
esc_url( admin_url( 'plugins.php' ) )
)
);
}
add_action( 'admin_notices', 'sqlite_plugin_admin_notice' ); // Add the admin notices.
// Remove the PL-plugin admin notices for SQLite.
remove_action( 'admin_notices', 'perflab_sqlite_plugin_admin_notice' );
@@ -0,0 +1,174 @@
<?php
/**
* Functions for the admin page of the plugin.
*
* @since 1.0.0
* @package wp-sqlite-integration
*/
/**
* Add an admin menu page.
*
* @since 1.0.0
*/
function sqlite_add_admin_menu() {
add_options_page(
__( 'SQLite integration', 'sqlite-database-integration' ),
__( 'SQLite integration', 'sqlite-database-integration' ),
'manage_options',
'sqlite-integration',
'sqlite_integration_admin_screen'
);
}
add_action( 'admin_menu', 'sqlite_add_admin_menu' );
/**
* The admin page contents.
*/
function sqlite_integration_admin_screen() {
$db_dropin_path = WP_CONTENT_DIR . '/db.php';
/*
* When an existing "db.php" drop-in is detected, let's check if it's a known
* plugin that we can continue supporting even when we override the drop-in.
*/
$override_db_dropin = false;
if ( file_exists( $db_dropin_path ) && ! defined( 'SQLITE_DB_DROPIN_VERSION' ) ) {
// Check for the Query Monitor plugin.
// When "QM_DB" exists, it must have been loaded via the "db.php" file.
if ( class_exists( 'QM_DB', false ) ) {
$override_db_dropin = true;
}
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'SQLite integration.', 'sqlite-database-integration' ); ?></h1>
</div>
<!-- Set the wrapper width to 50em, to improve readability. -->
<div style="max-width:50em;">
<?php if ( defined( 'SQLITE_DB_DROPIN_VERSION' ) ) : ?>
<div class="notice notice-success">
<p><?php esc_html_e( 'SQLite is enabled.', 'sqlite-database-integration' ); ?></p>
</div>
<p>
<?php
printf(
/* translators: 1: Admin URL to deactivate the module, 2: db.php drop-in path, */
__( 'The SQLite drop-in is enabled. To disable it and get back to your previous, MySQL database, you can <a href="%1$s">deactivate the plugin</a>. Alternatively, you can manually delete the %2$s file from your server.', 'sqlite-database-integration' ),
esc_url( admin_url( 'plugins.php' ) ),
'<code>' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php</code>'
);
?>
</p>
<?php elseif ( ! extension_loaded( 'pdo_sqlite' ) ) : ?>
<div class="notice notice-error">
<p><?php esc_html_e( 'We detected that the PDO SQLite driver is missing from your server (the pdo_sqlite extension is not loaded). Please make sure that SQLite is enabled in your PHP installation before proceeding.', 'sqlite-database-integration' ); ?></p>
</div>
<?php elseif ( file_exists( $db_dropin_path ) && ! defined( 'SQLITE_DB_DROPIN_VERSION' ) && ! $override_db_dropin ) : ?>
<?php if ( defined( 'PERFLAB_SQLITE_DB_DROPIN_VERSION' ) ) : ?>
<div class="notice notice-warning">
<p>
<?php
printf(
/* translators: %s: db.php drop-in path */
esc_html__( 'An older %s file was detected. Please click the button below to update the file.', 'sqlite-database-integration' ),
'<code>' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php</code>'
);
?>
</p>
</div>
<a class="button button-primary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=sqlite-integration&confirm-install&upgrade-from-pl' ), 'sqlite-install' ) ); ?>">
<?php
printf(
/* translators: %s: db.php drop-in path */
esc_html__( 'Update %s file', 'sqlite-database-integration' ),
'<code>' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php</code>'
);
?>
</a>
<?php else : ?>
<div class="notice notice-error">
<p>
<?php
printf(
/* translators: %s: db.php drop-in path */
esc_html__( 'The SQLite plugin cannot be activated because a different %s drop-in already exists.', 'sqlite-database-integration' ),
'<code>' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php</code>'
);
?>
</p>
</div>
<?php endif; ?>
<?php elseif ( ! is_writable( WP_CONTENT_DIR ) ) : ?>
<div class="notice notice-error">
<p>
<?php
printf(
/* translators: %s: db.php drop-in path */
esc_html__( 'The SQLite plugin cannot be activated because the %s directory is not writable.', 'sqlite-database-integration' ),
'<code>' . esc_html( basename( WP_CONTENT_DIR ) ) . '</code>'
);
?>
</p>
</div>
<?php else : ?>
<div class="notice notice-success">
<p><?php esc_html_e( 'All checks completed successfully, your site can use an SQLite database. You can proceed with the installation.', 'sqlite-database-integration' ); ?></p>
</div>
<h2><?php esc_html_e( 'Important note', 'sqlite-database-integration' ); ?></h2>
<p><?php esc_html_e( 'This plugin will switch to a separate database and install WordPress in it. You will need to reconfigure your site, and start with a fresh site. Disabling the plugin you will get back to your previous MySQL database, with all your previous data intact.', 'sqlite-database-integration' ); ?></p>
<?php if ( $override_db_dropin ) : ?>
<p>
<strong>NOTE:</strong>
<?php
printf(
/* translators: %s: db.php drop-in path */
esc_html__( 'Weve detected an existing database drop-in file at %s, created by the Query Monitor plugin.', 'sqlite-database-integration' ),
'<code>' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php</code>'
);
?>
<?php esc_html_e( 'To enable SQLite support, this file will need to be replaced.', 'sqlite-database-integration' ); ?>
<?php esc_html_e( 'The Query Monitor plugin will continue to function correctly after the change. You can safely proceed with the installation.', 'sqlite-database-integration' ); ?>
</p>
<?php endif; ?>
<p><?php esc_html_e( 'By clicking the button below, you will be redirected to the WordPress installation screen to setup your new database', 'sqlite-database-integration' ); ?></p>
<a class="button button-primary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=sqlite-integration&confirm-install' ), 'sqlite-install' ) ); ?>"><?php esc_html_e( 'Install SQLite database', 'sqlite-database-integration' ); ?></a>
<?php endif; ?>
</div>
<?php
}
/**
* Adds a link to the admin bar.
*
* @since n.e.x.t
*
* @global wpdb $wpdb WordPress database abstraction object.
*
* @param WP_Admin_Bar $admin_bar The admin bar object.
*/
function sqlite_plugin_adminbar_item( $admin_bar ) {
global $wpdb;
if ( defined( 'SQLITE_DB_DROPIN_VERSION' ) && defined( 'DB_ENGINE' ) && 'sqlite' === DB_ENGINE ) {
$title = '<span style="color:#46B450;">' . __( 'Database: SQLite', 'sqlite-database-integration' ) . '</span>';
} elseif ( stripos( $wpdb->db_server_info(), 'maria' ) !== false ) {
$title = '<span style="color:#DC3232;">' . __( 'Database: MariaDB', 'sqlite-database-integration' ) . '</span>';
} else {
$title = '<span style="color:#DC3232;">' . __( 'Database: MySQL', 'sqlite-database-integration' ) . '</span>';
}
$args = array(
'id' => 'sqlite-db-integration',
'parent' => 'top-secondary',
'title' => $title,
'href' => esc_url( admin_url( 'options-general.php?page=sqlite-integration' ) ),
'meta' => false,
);
$admin_bar->add_node( $args );
}
add_action( 'admin_bar_menu', 'sqlite_plugin_adminbar_item', 999 );
@@ -0,0 +1,53 @@
<?php
/**
* Define constants for the SQLite implementation.
*
* @since 1.0.0
* @package wp-sqlite-integration
*/
// Temporary - This will be in wp-config.php once SQLite is merged in Core.
if ( ! defined( 'DB_ENGINE' ) ) {
if ( defined( 'SQLITE_DB_DROPIN_VERSION' ) ) {
define( 'DB_ENGINE', 'sqlite' );
} elseif ( defined( 'DATABASE_ENGINE' ) ) {
// backwards compatibility with previous versions of the plugin.
define( 'DB_ENGINE', DATABASE_ENGINE );
} else {
define( 'DB_ENGINE', 'mysql' );
}
}
/**
* Notice:
* Your scripts have the permission to create directories or files on your server.
* If you write in your wp-config.php like below, we take these definitions.
* define('DB_DIR', '/full_path_to_the_database_directory/');
* define('DB_FILE', 'database_file_name');
*/
/**
* FQDBDIR is a directory where the sqlite database file is placed.
* If DB_DIR is defined, it is used as FQDBDIR.
*/
if ( ! defined( 'FQDBDIR' ) ) {
if ( defined( 'DB_DIR' ) ) {
define( 'FQDBDIR', trailingslashit( DB_DIR ) );
} elseif ( defined( 'WP_CONTENT_DIR' ) ) {
define( 'FQDBDIR', WP_CONTENT_DIR . '/database/' );
} else {
define( 'FQDBDIR', ABSPATH . 'wp-content/database/' );
}
}
/**
* FQDB is a database file name. If DB_FILE is defined, it is used
* as FQDB.
*/
if ( ! defined( 'FQDB' ) ) {
if ( defined( 'DB_FILE' ) ) {
define( 'FQDB', FQDBDIR . DB_FILE );
} else {
define( 'FQDB', FQDBDIR . '.ht.sqlite' );
}
}
@@ -0,0 +1,61 @@
<?php
/**
* Plugin Name: SQLite integration (Drop-in)
* Version: 1.0.0
* Author: WordPress Performance Team
* Author URI: https://make.wordpress.org/performance/
*
* This file is auto-generated and copied from the sqlite plugin.
* Please don't edit this file directly.
*
* @package wp-sqlite-integration
*/
define( 'SQLITE_DB_DROPIN_VERSION', '1.8.0' );
// Tweak to allow copy-pasting the file without having to run string-replacements.
$sqlite_plugin_implementation_folder_path = '{SQLITE_IMPLEMENTATION_FOLDER_PATH}';
if ( ! file_exists( $sqlite_plugin_implementation_folder_path ) ) { // Check that the folder exists.
$sqlite_plugin_implementation_folder_path = realpath( __DIR__ . '/plugins/sqlite-database-integration' );
}
// Bail early if the SQLite implementation was not located in the plugin.
if ( ! $sqlite_plugin_implementation_folder_path || ! file_exists( $sqlite_plugin_implementation_folder_path . '/wp-includes/sqlite/db.php' ) ) {
return;
}
// Constant for backward compatibility.
if ( ! defined( 'DATABASE_TYPE' ) ) {
define( 'DATABASE_TYPE', 'sqlite' );
}
// Define SQLite constant.
if ( ! defined( 'DB_ENGINE' ) ) {
define( 'DB_ENGINE', 'sqlite' );
}
// Require the implementation from the plugin.
require_once $sqlite_plugin_implementation_folder_path . '/wp-includes/sqlite/db.php';
// Activate the performance-lab plugin if it is not already activated.
add_action(
'admin_footer',
function() {
if ( defined( 'SQLITE_MAIN_FILE' ) ) {
return;
}
if ( ! function_exists( 'activate_plugin' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin_path = WP_PLUGIN_DIR . '/' . '{SQLITE_PLUGIN}';
if ( file_exists( $plugin_path ) && is_plugin_inactive( '{SQLITE_PLUGIN}' ) ) {
// If `activate_plugin()` returns a value other than null (like WP_Error),
// the plugin could not be found. Try with a hardcoded string,
// because that probably means the file was directly copy-pasted.
if ( null !== activate_plugin( '{SQLITE_PLUGIN}', '', false, true ) ) {
activate_plugin( 'sqlite-database-integration/load.php', '', false, true );
}
}
}
);
@@ -0,0 +1,68 @@
<?php
/**
* Handle the SQLite deactivation.
*
* @since 1.0.0
* @package wp-sqlite-integration
*/
/**
* Delete the db.php file in wp-content.
*
* When the plugin gets merged in wp-core, this is not to be ported.
*/
function sqlite_plugin_remove_db_file() {
if ( ! defined( 'SQLITE_DB_DROPIN_VERSION' ) || ! file_exists( WP_CONTENT_DIR . '/db.php' ) ) {
return;
}
global $wp_filesystem;
require_once ABSPATH . '/wp-admin/includes/file.php';
// Init the filesystem if needed, then delete custom drop-in.
if ( $wp_filesystem || WP_Filesystem() ) {
// Flush any persistent cache.
wp_cache_flush();
// Delete the drop-in.
$wp_filesystem->delete( WP_CONTENT_DIR . '/db.php' );
// Flush the cache again to mitigate a possible race condition.
wp_cache_flush();
}
// Run an action on `shutdown`, to deactivate the option in the MySQL database.
add_action(
'shutdown',
function () {
global $table_prefix;
// Get credentials for the MySQL database.
$dbuser = defined( 'DB_USER' ) ? DB_USER : '';
$dbpassword = defined( 'DB_PASSWORD' ) ? DB_PASSWORD : '';
$dbname = defined( 'DB_NAME' ) ? DB_NAME : '';
$dbhost = defined( 'DB_HOST' ) ? DB_HOST : '';
// Init a connection to the MySQL database.
$wpdb_mysql = new wpdb( $dbuser, $dbpassword, $dbname, $dbhost );
$wpdb_mysql->set_prefix( $table_prefix );
// Get the perflab options, remove the database/sqlite module and update the option.
$row = $wpdb_mysql->get_row( $wpdb_mysql->prepare( "SELECT option_value FROM $wpdb_mysql->options WHERE option_name = %s LIMIT 1", 'active_plugins' ) );
if ( is_object( $row ) ) {
$value = maybe_unserialize( $row->option_value );
if ( is_array( $value ) ) {
$value_flipped = array_flip( $value );
$items = array_reverse( explode( DIRECTORY_SEPARATOR, SQLITE_MAIN_FILE ) );
$item = $items[1] . DIRECTORY_SEPARATOR . $items[0];
unset( $value_flipped[ $item ] );
$value = array_flip( $value_flipped );
$wpdb_mysql->update( $wpdb_mysql->options, array( 'option_value' => maybe_serialize( $value ) ), array( 'option_name' => 'active_plugins' ) );
}
}
},
PHP_INT_MAX
);
// Flush any persistent cache.
wp_cache_flush();
}
register_deactivation_hook( SQLITE_MAIN_FILE, 'sqlite_plugin_remove_db_file' ); // Remove db.php file on plugin deactivation.
@@ -0,0 +1,83 @@
<?php
/**
* Tweaks for the health-check screens.
*
* @since 1.0.0
* @package wp-sqlite-integration
*/
/**
* Filter debug data in site-health screen.
*
* When the plugin gets merged in wp-core, these should be merged in src/wp-admin/includes/class-wp-debug-data.php
* See https://github.com/WordPress/wordpress-develop/pull/3220/files
*
* @param array $info The debug data.
*/
function sqlite_plugin_filter_debug_data( $info ) {
$db_engine = defined( 'DB_ENGINE' ) && 'sqlite' === DB_ENGINE ? 'sqlite' : 'mysql';
$info['wp-constants']['fields']['DB_ENGINE'] = array(
'label' => 'DB_ENGINE',
'value' => ( defined( 'DB_ENGINE' ) ? DB_ENGINE : __( 'Undefined', 'sqlite-database-integration' ) ),
'debug' => ( defined( 'DB_ENGINE' ) ? DB_ENGINE : 'undefined' ),
);
$info['wp-database']['fields']['db_engine'] = array(
'label' => __( 'Database type', 'sqlite-database-integration' ),
'value' => 'sqlite' === $db_engine ? 'SQLite' : 'MySQL/MariaDB',
);
if ( 'sqlite' === $db_engine ) {
$info['wp-database']['fields']['database_version'] = array(
'label' => __( 'SQLite version', 'sqlite-database-integration' ),
'value' => $info['wp-database']['fields']['server_version'] ?? null,
);
$info['wp-database']['fields']['database_file'] = array(
'label' => __( 'Database file', 'sqlite-database-integration' ),
'value' => FQDB,
'private' => true,
);
$info['wp-database']['fields']['database_size'] = array(
'label' => __( 'Database size', 'sqlite-database-integration' ),
'value' => size_format( filesize( FQDB ) ),
);
unset( $info['wp-database']['fields']['extension'] );
unset( $info['wp-database']['fields']['server_version'] );
unset( $info['wp-database']['fields']['client_version'] );
unset( $info['wp-database']['fields']['database_host'] );
unset( $info['wp-database']['fields']['database_user'] );
unset( $info['wp-database']['fields']['database_name'] );
unset( $info['wp-database']['fields']['database_charset'] );
unset( $info['wp-database']['fields']['database_collate'] );
unset( $info['wp-database']['fields']['max_allowed_packet'] );
unset( $info['wp-database']['fields']['max_connections'] );
}
return $info;
}
add_filter( 'debug_information', 'sqlite_plugin_filter_debug_data' ); // Filter debug data in site-health screen.
/**
* Filter site_status tests in site-health screen.
*
* When the plugin gets merged in wp-core, these should be merged in src/wp-admin/includes/class-wp-site-health.php
*
* @param array $tests The tests.
* @return array
*/
function sqlite_plugin_filter_site_status_tests( $tests ) {
$db_engine = defined( 'DB_ENGINE' ) && 'sqlite' === DB_ENGINE ? 'sqlite' : 'mysql';
if ( 'sqlite' === $db_engine ) {
unset( $tests['direct']['utf8mb4_support'] );
unset( $tests['direct']['sql_server'] );
unset( $tests['direct']['persistent_object_cache'] ); // Throws an error because DB_NAME is not defined.
}
return $tests;
}
add_filter( 'site_status_tests', 'sqlite_plugin_filter_site_status_tests' );
@@ -0,0 +1,172 @@
<?php
/**
* Boot Query Monitor from the SQLite Database Integration plugin.
*
* When the Query Monitor plugin exists in its standard location, let's check
* if it is active, so we can boot it eagerly. This is a workaround to avoid
* SQLite and Query Monitor competing for the "wp-content/db.php" file.
*
* This file is a modified version of the original Query Monitor "db.php" file.
*
* See: https://github.com/johnbillion/query-monitor/blob/develop/wp-content/db.php
*/
/*
* In Playground, the SQLite plugin is preloaded without using the "db.php" file.
* To prevent Query Monitor from injecting its own "db.php" file, we need to set
* the "QM_DB_SYMLINK" constant to "false".
*/
if ( ! defined( 'QM_DB_SYMLINK' ) ) {
define( 'QM_DB_SYMLINK', false );
}
// Check if we should load Query Monitor (as per the original "db.php" file).
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! defined( 'DB_USER' ) ) {
return;
}
if ( defined( 'QM_DISABLED' ) && QM_DISABLED ) {
return;
}
if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
return;
}
if ( 'cli' === php_sapi_name() && ! defined( 'QM_TESTS' ) ) {
return;
}
if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
return;
}
if ( is_admin() ) {
if ( isset( $_GET['action'] ) && 'upgrade-plugin' === $_GET['action'] ) {
return;
}
if ( isset( $_POST['action'] ) && 'update-plugin' === $_POST['action'] ) {
return;
}
}
/*
* Register SQLite enhancements for Query Monitor when plugins are loaded.
*
* This will also ensure that the plugin Query Monitor is fully initialized even
* when we can't load it eagerly, e.g. on a multisite install.
*/
function register_sqlite_enhancements_for_query_monitor() {
if ( ! class_exists( 'QM_Backtrace' ) ) {
return;
}
if ( defined( 'QM_VERSION' ) && version_compare( QM_VERSION, '4.0.0', '>=' ) ) {
require_once __DIR__ . '/qm4.php';
} else {
require_once __DIR__ . '/qm3.php';
}
if ( ! defined( 'SQLITE_QUERY_MONITOR_LOADED' ) ) {
define( 'SQLITE_QUERY_MONITOR_LOADED', true );
}
}
if ( function_exists( 'add_action' ) ) {
add_action( 'plugins_loaded', 'register_sqlite_enhancements_for_query_monitor' );
}
/*
* On a multisite install, we can't easily determine the current site eagerly.
* Therefore, let's bail out and let Query Monitor activate later as a plugin.
*/
if ( is_multisite() ) {
return;
}
/*
* Now, let's try to load Query Monitor eagerly, to start logging queries early.
* When the plugin is installed, we will check the database if it is also active.
*/
global $wpdb;
if ( ! isset( $wpdb ) ) {
return;
}
// Check if Query Monitor is installed.
if ( defined( 'WP_PLUGIN_DIR' ) ) {
$plugins_dir = WP_PLUGIN_DIR;
} else {
$plugins_dir = WP_CONTENT_DIR . '/plugins';
}
$qm_dir = "{$plugins_dir}/query-monitor";
$qm_php = "{$qm_dir}/classes/PHP.php";
if ( ! is_readable( $qm_php ) ) {
return;
}
// Check if Query Monitor is active.
if ( null === $wpdb->options ) {
global $table_prefix;
$wpdb->set_prefix( $table_prefix ?? '' );
}
$query_monitor_active = false;
try {
// Make sure no errors are displayed when the query fails.
$show_errors = $wpdb->hide_errors();
$value = $wpdb->get_row(
$wpdb->prepare(
"SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1",
'active_plugins'
)
);
$wpdb->show_errors( $show_errors );
if ( null !== $value ) {
$query_monitor_active = in_array(
'query-monitor/query-monitor.php',
unserialize( $value->option_value ),
true
);
}
} catch ( Throwable $e ) {
return;
}
if ( ! $query_monitor_active ) {
return;
}
// Load Query Monitor eagerly (as per the original "db.php" file).
require_once $qm_php;
if ( ! QM_PHP::version_met() ) {
return;
}
if ( ! file_exists( "{$qm_dir}/vendor/autoload.php" ) ) {
add_action( 'all_admin_notices', 'QM_PHP::vendor_nope' );
return;
}
require_once "{$qm_dir}/vendor/autoload.php";
if ( ! class_exists( 'QM_Backtrace' ) ) {
return;
}
if ( ! defined( 'SAVEQUERIES' ) ) {
define( 'SAVEQUERIES', true );
}
// Mark the Query Monitor integration as loaded.
define( 'SQLITE_QUERY_MONITOR_LOADED', true );
@@ -0,0 +1,89 @@
<?php // phpcs:disable WordPress.Files.FileName.InvalidClassFileName
if ( ! class_exists( 'QM_Output_Html_DB_Queries' ) || ! class_exists( 'QM_Collectors' ) ) {
return;
}
/**
* Override Query Monitor's "QM_Output_Html_DB_Queries" to inject SQLite info.
*/
class SQLite_QM_Output_Html_DB_Queries extends QM_Output_Html_DB_Queries {
/**
* Override the parent method to inject SQLite info.
*
* Currently, Query Monitor doesn't provide a way to customize the rendered
* query HTML. To overcome this limitation, we capture the HTML generated by
* the parent method and modify it to inject the SQLite query data.
*
* @param array<string, mixed> $row The row data.
* @param array<int, string> $cols The column names.
* @return void
*/
protected function output_query_row( array $row, array $cols ) {
// Capture the query row HTML.
ob_start();
parent::output_query_row( $row, $cols );
$data = ob_get_length() > 0 ? ob_get_clean() : '';
// Get the corresponding SQLite queries.
global $wpdb;
static $query_index = 0;
$sqlite_queries = $wpdb->queries[ $query_index ]['sqlite_queries'] ?? array();
$sqlite_query_count = count( $sqlite_queries );
$query_index += 1;
// Build the SQLite info HTML.
$sqlite_info = sprintf(
'<div class="qm-info" style="margin: 15px 0 8px;">Executed %d SQLite %s:</div>',
$sqlite_query_count,
1 === $sqlite_query_count ? 'Query' : 'Queries'
);
$sqlite_info .= '<ol>';
foreach ( $sqlite_queries as $query ) {
$sqlite_info .= '<li class="qm-sqlite-query" style="list-style: decimal !important; margin-left: 20px !important;">';
$sqlite_info .= '<code>' . str_replace( '<br>', '', self::format_sql( $query['sql'] ) ) . '</code>';
$sqlite_info .= '</li>';
}
$sqlite_info .= '</ol>';
// Inject toggle button and SQLite info into the query row HTML.
$toggle_button = '<button class="qm-toggle sqlite-toggle" data-on="+" data-off="-" aria-expanded="false" aria-label="Toggle SQLite queries" title="Toggle SQLite queries"><span aria-hidden="true">+</span></button>';
$toggle_content = sprintf( '<div class="qm-toggled" style="display: none;">%s</div>', $sqlite_info );
$data = str_replace( 'qm-row-sql', 'qm-row-sql qm-has-toggle', $data );
$data = preg_replace(
'/(<td class="qm-row-sql.*?">)(.*?)(<\/td>)/s',
implode(
array(
'$1',
str_replace( '$', '\\$', $toggle_button ),
'$2',
str_replace( '$', '\\$', $toggle_content ),
'$3',
)
),
$data
);
echo $data;
}
}
// Remove the default Query Monitor class and replace it with the custom one.
remove_filter( 'qm/outputter/html', 'register_qm_output_html_db_queries', 20 );
/**
* Register the custom HTML output class.
*
* @param array<string, QM_Output> $output
* @param QM_Collectors $collectors
* @return array<string, QM_Output>
*/
function register_sqlite_qm_output_html_db_queries( array $output, $collectors ) {
$collector = QM_Collectors::get( 'db_queries' );
if ( $collector ) {
$output['db_queries'] = new SQLite_QM_Output_Html_DB_Queries( $collector );
}
return $output;
}
add_filter( 'qm/outputter/html', 'register_sqlite_qm_output_html_db_queries', 20, 2 );
@@ -0,0 +1,107 @@
<?php // phpcs:disable WordPress.Files.FileName.InvalidClassFileName, Generic.Files.OneObjectStructurePerFile.MultipleFound
if ( ! class_exists( 'QM_Collector' ) ) {
return;
}
/**
* Data transfer object for SQLite query data.
*/
class SQLite_QM_Data extends QM_Data {
/**
* SQLite queries indexed by normalized MySQL SQL text.
*
* @var array<string, list<string>>
*/
public $queries = array();
}
/**
* Collector for SQLite query data.
*
* Extracts SQLite queries from $wpdb->queries and stores them
* indexed by SQL text for the QM 4.0+ JS integration.
*/
class SQLite_QM_Collector extends QM_Collector {
/** @var string */
public $id = 'sqlite';
public function get_storage(): QM_Data {
return new SQLite_QM_Data();
}
public function process(): void {
global $wpdb;
if ( empty( $wpdb->queries ) ) {
return;
}
// Index by SQL rather than row position — robust to filtering, sorting, etc.
$mapped = array();
foreach ( $wpdb->queries as $query ) {
// Query Monitor skips queries with 'wp_admin_bar' in the stack.
if ( false !== strpos( $query[2] ?? '', 'wp_admin_bar' ) ) {
continue;
}
if ( ! empty( $query['sqlite_queries'] ) ) {
$sql = trim( preg_replace( '/\s+/', ' ', $query[0] ) );
$mapped[ $sql ] = array_column( $query['sqlite_queries'], 'sql' );
}
}
$this->data->queries = $mapped;
}
}
/**
* HTML outputter for SQLite query data.
*
* With $client_side_rendered = true, QM auto-serializes the collector data
* into "window.QueryMonitorData.data.sqlite". This outputter's only job is to
* emit the inline JS module that reads that data and injects SQLite query
* details into QM 4.0's shadow DOM DB queries panel.
*/
class SQLite_QM_Output_Html extends QM_Output_Html {
/** @var bool */
public static $client_side_rendered = true;
public function name(): string {
return 'SQLite';
}
public function output(): void {
if ( empty( $this->get_collector()->get_data()->queries ) ) {
return;
}
$js_path = __DIR__ . '/query-monitor-sqlite.js';
if ( is_readable( $js_path ) ) {
echo '<script type="module">';
include $js_path;
echo '</script>';
}
}
}
/**
* Register the SQLite collector.
*/
function register_sqlite_qm_collector( array $collectors ): array {
$collectors['sqlite'] = new SQLite_QM_Collector();
return $collectors;
}
add_filter( 'qm/collectors', 'register_sqlite_qm_collector', 20 );
/**
* Register the SQLite HTML outputter.
*/
function register_sqlite_qm_output_html( array $output, QM_Collectors $collectors ): array {
$collector = QM_Collectors::get( 'sqlite' );
if ( $collector ) {
$output['sqlite'] = new SQLite_QM_Output_Html( $collector );
}
return $output;
}
add_filter( 'qm/outputter/html', 'register_sqlite_qm_output_html', 30, 2 );
@@ -0,0 +1,95 @@
const STYLE = `
details.qm-sqlite {
margin: 6px 0 0;
}
details.qm-sqlite summary {
cursor: pointer;
}
details.qm-sqlite ol {
margin: 6px 0 0;
padding-left: 24px;
list-style: decimal;
}
`;
const container = document.getElementById( 'query-monitor-container' );
const sqliteData = window.QueryMonitorData?.data?.sqlite?.data?.queries;
if ( container && sqliteData ) {
// QM attaches the shadow root in its own DOMContentLoaded listener.
// Our module is loaded after QM's, so our listener fires after QM's.
document.addEventListener( 'DOMContentLoaded', () => {
const shadowRoot = container.shadowRoot;
if ( ! shadowRoot ) {
return;
}
inject( shadowRoot, sqliteData );
// Re-inject after Preact re-renders (panel switches, filters, etc.).
// Debounced to avoid excessive work during rapid DOM updates.
let timer;
new MutationObserver( () => {
clearTimeout( timer );
timer = setTimeout( () => inject( shadowRoot, sqliteData ), 100 );
} ).observe( shadowRoot, { childList: true, subtree: true } );
} );
}
function inject( shadowRoot, data ) {
const panel = shadowRoot.getElementById( 'qm-db_queries' );
if ( ! panel ) {
return;
}
if ( ! shadowRoot.querySelector( 'style.qm-sqlite-style' ) ) {
const style = document.createElement( 'style' );
style.className = 'qm-sqlite-style';
style.textContent = STYLE;
shadowRoot.appendChild( style );
}
// Match by SQL rather than row position — robust to filtering, sorting, etc.
for ( const code of panel.querySelectorAll( 'td.qm-cell-sql > code' ) ) {
const cell = code.parentElement;
const key = code.innerText.replace( /\s+/g, ' ' ).trim();
const queries = data[ key ];
const existing = cell.querySelector( 'details.qm-sqlite' );
// Preact may recycle DOM nodes on filter/sort, leaving stale details
// from a previous query. Remove them when the SQL key no longer matches.
if ( existing ) {
if ( queries?.length && existing.dataset.sqliteKey === key ) {
continue;
}
existing.remove();
}
if ( queries?.length ) {
cell.append( buildDetails( key, queries ) );
}
}
}
function buildDetails( key, queries ) {
const details = document.createElement( 'details' );
details.className = 'qm-sqlite';
details.dataset.sqliteKey = key;
// Prevent QM's row click handlers from firing when toggling.
details.addEventListener( 'click', ( e ) => e.stopPropagation() );
const summary = document.createElement( 'summary' );
summary.textContent = `Executed ${ queries.length } SQLite ${ queries.length === 1 ? 'Query' : 'Queries' }`;
const ol = document.createElement( 'ol' );
for ( const sql of queries ) {
const li = document.createElement( 'li' );
li.className = 'qm-sqlite-query';
const code = document.createElement( 'code' );
code.textContent = sql;
li.append( code );
ol.append( li );
}
details.append( summary, ol );
return details;
}
@@ -0,0 +1,27 @@
<?php
/**
* Plugin Name: SQLite Database Integration
* Description: SQLite database driver drop-in.
* Author: The WordPress Team
* Version: 3.0.0-rc.4
* Requires PHP: 7.2
* Textdomain: sqlite-database-integration
*
* This feature plugin allows WordPress to use SQLite instead of MySQL as its database.
*
* @package wp-sqlite-integration
*/
/**
* Load the "SQLITE_DRIVER_VERSION" constant.
* This constant needs to be updated on plugin release!
*/
require_once __DIR__ . '/wp-includes/database/version.php';
define( 'SQLITE_MAIN_FILE', __FILE__ );
require_once __DIR__ . '/admin-page.php';
require_once __DIR__ . '/activate.php';
require_once __DIR__ . '/deactivate.php';
require_once __DIR__ . '/admin-notices.php';
require_once __DIR__ . '/health-check.php';
@@ -0,0 +1,90 @@
=== SQLite Database Integration ===
Contributors: wordpressdotorg, aristath, janjakes, zieladam, berislav.grgicak, bpayton, zaerl
Requires at least: 6.4
Tested up to: 6.9
Requires PHP: 7.2
Stable tag: 3.0.0-rc.4
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Tags: performance, database
SQLite integration plugin by the WordPress Team.
== Description ==
The SQLite plugin is a community, feature plugin. The intent is to allow testing an SQLite integration with WordPress and gather feedback, with the goal of eventually landing it in WordPress core.
== Frequently Asked Questions ==
= What is the purpose of this plugin? =
The primary purpose of the SQLite plugin is to allow testing the use of an SQLite database, with the goal to eventually land in WordPress core.
You can read the original proposal on the [Make blog](https://make.wordpress.org/core/2022/09/12/lets-make-wordpress-officially-support-sqlite/), as well as the [call for testing](https://make.wordpress.org/core/2022/12/20/help-us-test-the-sqlite-implementation/) for more context and useful information.
= Can I use this plugin on my production site? =
Per the primary purpose of the plugin (see above), it can mostly be considered a beta testing plugin. To a degree, it should be okay to use it in production. However, as with every plugin, you are doing so at your own risk.
= Where can I submit my plugin feedback? =
Feedback is encouraged and much appreciated, especially since this plugin is a future WordPress core feature. If you need help with troubleshooting or have a question, suggestions, or requests, you can [submit them as an issue in the SQLite GitHub repository](https://github.com/wordpress/sqlite-database-integration/issues/new).
= How can I contribute to the plugin? =
Contributions are always welcome! Learn more about how to get involved in the [Core Performance Team Handbook](https://make.wordpress.org/performance/handbook/get-involved/).
= Does this plugin change how WordPress queries are executed? =
The plugin replaces the default MySQL-based database layer with an
SQLite-backed implementation. Core WordPress code continues to use
the wpdb API, while queries are internally adapted to be compatible
with SQLite syntax and behavior.
== Changelog ==
= 3.0.0-rc.4 =
* Normalize BIT column default literals ([#439](https://github.com/WordPress/sqlite-database-integration/pull/439))
* Optimize MySQL lexer (~2× speedup) ([#424](https://github.com/WordPress/sqlite-database-integration/pull/424))
* Fix `RAND()` function behavior ([#363](https://github.com/WordPress/sqlite-database-integration/pull/363))
* CI: Disable Xdebug, use Rust release builds, consolidate unit-test matrix ([#425](https://github.com/WordPress/sqlite-database-integration/pull/425))
* GitHub Actions workflow updates ([#404](https://github.com/WordPress/sqlite-database-integration/pull/404))
* Add native Rust-based MySQL parser extension ([#381](https://github.com/WordPress/sqlite-database-integration/pull/381), [#384](https://github.com/WordPress/sqlite-database-integration/pull/384), [#386](https://github.com/WordPress/sqlite-database-integration/pull/386), [#389](https://github.com/WordPress/sqlite-database-integration/pull/389), [#390](https://github.com/WordPress/sqlite-database-integration/pull/390), [#394](https://github.com/WordPress/sqlite-database-integration/pull/394), [#398](https://github.com/WordPress/sqlite-database-integration/pull/398))
* Ship native parser as WASM ([#395](https://github.com/WordPress/sqlite-database-integration/pull/395), [#396](https://github.com/WordPress/sqlite-database-integration/pull/396), [#397](https://github.com/WordPress/sqlite-database-integration/pull/397), [#399](https://github.com/WordPress/sqlite-database-integration/pull/399), [#400](https://github.com/WordPress/sqlite-database-integration/pull/400), [#401](https://github.com/WordPress/sqlite-database-integration/pull/401))
* Add SQLite plugin landing page ([#407](https://github.com/WordPress/sqlite-database-integration/pull/407), [#412](https://github.com/WordPress/sqlite-database-integration/pull/412))
* Check Playground web runtime compatibility ([#419](https://github.com/WordPress/sqlite-database-integration/pull/419))
= 3.0.0-rc.3 =
* Lexer: Fix possible OOB read in quoted strings ([#374](https://github.com/WordPress/sqlite-database-integration/pull/374))
* Add support for `NO_AUTO_VALUE_ON_ZERO` SQL mode ([#366](https://github.com/WordPress/sqlite-database-integration/pull/366))
= 3.0.0-rc.2 =
* Support MySQL `BINARY` operator ([#369](https://github.com/WordPress/sqlite-database-integration/pull/369))
* Add support for `AUTO_INCREMENT` value management ([#367](https://github.com/WordPress/sqlite-database-integration/pull/367))
* Add support for `DELETE` with `LIMIT` and `ORDER BY` ([#365](https://github.com/WordPress/sqlite-database-integration/pull/365))
= 3.0.0-rc.1 =
* Improve concurrent database access ([#361](https://github.com/WordPress/sqlite-database-integration/pull/361))
* Remove legacy SQLite driver ([#358](https://github.com/WordPress/sqlite-database-integration/pull/358))
= 2.2.23 =
* Add Query Monitor 4.0 support ([#357](https://github.com/WordPress/sqlite-database-integration/pull/357))
* Translate MySQL CONVERT() expressions to SQLite ([#356](https://github.com/WordPress/sqlite-database-integration/pull/356))
= 2.2.22 =
* Support INSERT without INTO keyword ([#354](https://github.com/WordPress/sqlite-database-integration/pull/354))
* Add tests for MySQL row-level locking clauses ([#342](https://github.com/WordPress/sqlite-database-integration/pull/342))
* Improve automated deploy setup.
= 2.2.21 =
* Monorepo setup + release automation ([#334](https://github.com/WordPress/sqlite-database-integration/pull/334))
* Rework release workflow ([#350](https://github.com/WordPress/sqlite-database-integration/pull/350))
* Fix incorrect PHP polyfill implementations ([#338](https://github.com/WordPress/sqlite-database-integration/pull/338))
@@ -0,0 +1,45 @@
<?php
define( 'WP_MYSQL_ON_SQLITE_LOADER_PATH', __FILE__ );
/**
* Load the PDO MySQL-on-SQLite driver and its dependencies.
*/
require_once __DIR__ . '/php-polyfills.php';
require_once __DIR__ . '/version.php';
require_once __DIR__ . '/parser/class-wp-parser-grammar.php';
require_once __DIR__ . '/parser/class-wp-parser.php';
require_once __DIR__ . '/parser/class-wp-parser-node.php';
require_once __DIR__ . '/parser/class-wp-parser-token.php';
require_once __DIR__ . '/mysql/class-wp-mysql-token.php';
/*
* The MySQL lexer and parser have an optional native (e.g. Rust) implementation.
* When the native extension is loaded, it pre-declares WP_MySQL_Native_Lexer /
* WP_MySQL_Native_Parser; otherwise we fall back to the pure-PHP classes shipped
* here. WP_MySQL_Lexer / WP_MySQL_Parser is the public entrypoint either way.
*/
if ( class_exists( 'WP_MySQL_Native_Lexer', false ) ) {
require_once __DIR__ . '/mysql/native/class-wp-mysql-lexer.php';
} else {
require_once __DIR__ . '/mysql/class-wp-mysql-lexer.php';
}
if ( class_exists( 'WP_MySQL_Native_Parser', false ) ) {
require_once __DIR__ . '/mysql/native/mysql-rust-bridge.php';
require_once __DIR__ . '/mysql/native/class-wp-mysql-native-parser-node.php';
require_once __DIR__ . '/mysql/native/trait-wp-mysql-native-parser-impl.php';
require_once __DIR__ . '/mysql/native/class-wp-mysql-parser.php';
} else {
require_once __DIR__ . '/mysql/class-wp-mysql-parser.php';
}
require_once __DIR__ . '/sqlite/class-wp-sqlite-connection.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-configurator.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-driver.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-driver-exception.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-information-schema-builder.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-information-schema-exception.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-information-schema-reconstructor.php';
require_once __DIR__ . '/sqlite/class-wp-sqlite-pdo-user-defined-functions.php';
require_once __DIR__ . '/sqlite/class-wp-pdo-mysql-on-sqlite.php';
require_once __DIR__ . '/sqlite/class-wp-pdo-proxy-statement.php';
@@ -0,0 +1,63 @@
<?php
class WP_MySQL_Parser extends WP_Parser {
/**
* The current query AST.
*
* @var WP_Parser_Node|null
*/
private $current_ast;
/**
* Reset this parser with a new token stream.
*
* @param array<WP_Parser_Token> $tokens The parser tokens.
*/
public function reset_tokens( array $tokens ): void {
$this->tokens = $tokens;
$this->position = 0;
$this->current_ast = null;
}
/**
* Parse the next query from the input SQL string.
*
* This method reads tokens until a query is parsed, or the parsing fails.
* It returns a boolean indicating whether a query was successfully parsed.
*
* Example:
*
* // Parse all queries in the input SQL string.
* $parser = new WP_MySQL_Parser( $sql );
* while ( $parser->next_query() ) {
* $ast = $parser->get_query_ast();
* if ( ! $ast ) {
* // The parsing failed.
* }
* // The query was successfully parsed.
* }
*
* @return bool Whether a query was successfully parsed.
*/
public function next_query(): bool {
if ( $this->position >= count( $this->tokens ) ) {
return false;
}
$this->current_ast = $this->parse();
return true;
}
/**
* Get the current query AST.
*
* When no query has been parsed yet, the parsing failed, or the end of the
* input was reached, this method returns null.
*
* @see WP_MySQL_Parser::next_query() for usage example.
*
* @return WP_Parser_Node|null The current query AST, or null if no query was parsed.
*/
public function get_query_ast(): ?WP_Parser_Node {
return $this->current_ast;
}
}
@@ -0,0 +1,187 @@
<?php
/**
* MySQL token.
*
* This class represents a MySQL SQL token that is produced by WP_MySQL_Lexer,
* and consumed by WP_MySQL_Parser during the parsing process.
*/
class WP_MySQL_Token extends WP_Parser_Token {
/**
* Whether the NO_BACKSLASH_ESCAPES SQL mode is enabled.
*
* @var bool
*/
private $sql_mode_no_backslash_escapes_enabled;
/**
* Constructor.
*
* @param int $id Token type.
* @param int $start Byte offset in the input where the token begins.
* @param int $length Byte length of the token in the input.
* @param string $input Input bytes from which the token was parsed.
* @param bool $sql_mode_no_backslash_escapes_enabled Whether the NO_BACKSLASH_ESCAPES SQL mode is enabled.
*/
public function __construct(
int $id,
int $start,
int $length,
string $input,
bool $sql_mode_no_backslash_escapes_enabled
) {
$this->id = $id;
$this->start = $start;
$this->length = $length;
$this->input = $input;
$this->sql_mode_no_backslash_escapes_enabled = $sql_mode_no_backslash_escapes_enabled;
}
/**
* Get the name of the token.
*
* This method is intended to be used only for testing and debugging purposes,
* when tokens need to be presented by their names in a human-readable form.
* It should not be used in production code, as it's not performance-optimized.
*
* @return string The token name.
*/
public function get_name(): string {
$name = WP_MySQL_Lexer::get_token_name( $this->id );
if ( null === $name ) {
$name = 'UNKNOWN';
}
return $name;
}
/**
* Get the real unquoted value of the token.
*
* @return string The token value.
*/
public function get_value(): string {
$value = $this->get_bytes();
if (
WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $this->id
|| WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $this->id
|| WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $this->id
) {
// Remove bounding quotes.
$quote = $value[0];
$value = substr( $value, 1, -1 );
/*
* When the NO_BACKSLASH_ESCAPES SQL mode is enabled, we only need to
* handle escaped bounding quotes, as the other characters preserve
* their literal values.
*/
if ( $this->sql_mode_no_backslash_escapes_enabled ) {
return str_replace( $quote . $quote, $quote, $value );
}
/**
* Unescape MySQL escape sequences.
*
* MySQL string literals use backslash as an escape character, and
* the string bounding quotes can also be escaped by being doubled.
*
* The escaping is done according to the following rules:
*
* 1. Some special character escape sequences are recognized.
* For example, "\n" is a newline character, "\0" is ASCII NULL.
* 2. A specific treatment is applied to "\%" and "\_" sequences.
* This is due to their special meaning for pattern matching.
* 3. Other backslash-prefixed characters resolve to their literal
* values. For example, "\x" represents "x", "\\" represents "\".
*
* Despite looking similar, these rules are different from the C-style
* string escaping, so we cannot use "strip(c)slashes()" in this case.
*
* See: https://dev.mysql.com/doc/refman/8.4/en/string-literals.html
*/
$backslash = chr( 92 );
$replacements = array(
/*
* MySQL special character escape sequences.
*/
( $backslash . '0' ) => chr( 0 ), // An ASCII NULL character (\0).
( $backslash . "'" ) => chr( 39 ), // A single quote character (').
( $backslash . '"' ) => chr( 34 ), // A double quote character (").
( $backslash . 'b' ) => chr( 8 ), // A backspace character.
( $backslash . 'n' ) => chr( 10 ), // A newline (linefeed) character (\n).
( $backslash . 'r' ) => chr( 13 ), // A carriage return character (\r).
( $backslash . 't' ) => chr( 9 ), // A tab character (\t).
( $backslash . 'Z' ) => chr( 26 ), // An ASCII 26 (Control+Z) character.
/*
* Normalize escaping of "%" and "_" characters.
*
* MySQL has unusual handling for "\%" and "\_" in all string literals.
* While other sequences follow the C-style escaping ("\?" is "?", etc.),
* "\%" resolves to "\%" and "\_" resolves to "\_" (unlike in C strings).
*
* This means that "\%" behaves like "\\%", and "\_" behaves like "\\_".
* To preserve this behavior, we need to add a second backslash here.
*
* From https://dev.mysql.com/doc/refman/8.4/en/string-literals.html:
* > The \% and \_ sequences are used to search for literal instances
* > of % and _ in pattern-matching contexts where they would otherwise
* > be interpreted as wildcard characters. If you use \% or \_ outside
* > of pattern-matching contexts, they evaluate to the strings \% and
* > \_, not to % and _.
*/
( $backslash . '%' ) => $backslash . $backslash . '%',
( $backslash . '_' ) => $backslash . $backslash . '_',
/*
* Preserve a double backslash as-is, so that the trailing backslash
* is not consumed as the beginning of an escape sequence like "\n".
*
* Resolving "\\" to "\" will be handled in the next step, where all
* other backslash-prefixed characters resolve to their literal values.
*/
( $backslash . $backslash )
=> $backslash . $backslash,
/*
* The bounding quotes can also be escaped by being doubled.
*/
( $quote . $quote ) => $quote,
);
/*
* Apply the replacements.
*
* It is important to use "strtr()" and not "str_replace()", because
* "str_replace()" applies replacements one after another, modifying
* intermediate changes rather than just the original string:
*
* - str_replace( [ 'a', 'b' ], [ 'b', 'c' ], 'ab' ); // 'cc' (bad)
* - strtr( 'ab', [ 'a' => 'b', 'b' => 'c' ] ); // 'bc' (good)
*/
$value = strtr( $value, $replacements );
/*
* A backslash with any other character represents the character itself.
* That is, \x evaluates to x, \\ evaluates to \, and \🙂 evaluates to 🙂.
*/
$preg_quoted_backslash = preg_quote( $backslash );
$value = preg_replace( "/$preg_quoted_backslash(.)/u", '$1', $value );
}
return $value;
}
/**
* Get the token representation as a string.
*
* This method is intended to be used only for testing and debugging purposes,
* when tokens need to be presented in a human-readable form. It should not
* be used in production code, as it's not performance-optimized.
*
* @return string
*/
public function __toString(): string {
return $this->get_value() . '<' . $this->id . ',' . $this->get_name() . '>';
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<?php
class WP_MySQL_Lexer extends WP_MySQL_Native_Lexer {}
@@ -0,0 +1,179 @@
<?php
/**
* Parser node backed by a native (Rust) AST.
*
* Constructed by the native MySQL parser extension. Read methods delegate
* into the Rust-owned AST so children are never copied into PHP unless a
* caller actually walks the tree. On the first mutation (append_child or
* merge_fragment), the node materializes its children into the inherited
* `$children` array and behaves like a plain WP_Parser_Node from then on.
*/
class WP_MySQL_Native_Parser_Node extends WP_Parser_Node {
private $was_mutated = false;
public function __construct( $rule_id, $rule_name ) {
parent::__construct( $rule_id, $rule_name );
}
public function __destruct() {
if ( function_exists( 'wp_sqlite_mysql_native_ast_release_wrapper' ) ) {
wp_sqlite_mysql_native_ast_release_wrapper( $this );
}
}
/** @inheritDoc */
public function append_child( $node ) {
$this->materialize_native_children();
parent::append_child( $node );
}
/** @inheritDoc */
public function merge_fragment( $node ) {
$this->materialize_native_children();
if ( $node instanceof self ) {
$node->materialize_native_children();
}
parent::merge_fragment( $node );
}
/** @inheritDoc */
public function has_child(): bool {
if ( $this->was_mutated ) {
return parent::has_child();
}
return wp_sqlite_mysql_native_ast_has_child( $this );
}
/** @inheritDoc */
public function has_child_node( ?string $rule_name = null ): bool {
if ( $this->was_mutated ) {
return parent::has_child_node( $rule_name );
}
return wp_sqlite_mysql_native_ast_has_child_node( $this, $rule_name );
}
/** @inheritDoc */
public function has_child_token( ?int $token_id = null ): bool {
if ( $this->was_mutated ) {
return parent::has_child_token( $token_id );
}
return wp_sqlite_mysql_native_ast_has_child_token( $this, $token_id );
}
/** @inheritDoc */
public function get_first_child() {
if ( $this->was_mutated ) {
return parent::get_first_child();
}
return wp_sqlite_mysql_native_ast_get_first_child( $this );
}
/** @inheritDoc */
public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_Node {
if ( $this->was_mutated ) {
return parent::get_first_child_node( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_first_child_node( $this, $rule_name );
}
/** @inheritDoc */
public function get_first_child_token( ?int $token_id = null ): ?WP_Parser_Token {
if ( $this->was_mutated ) {
return parent::get_first_child_token( $token_id );
}
return wp_sqlite_mysql_native_ast_get_first_child_token( $this, $token_id );
}
/** @inheritDoc */
public function get_first_descendant_node( ?string $rule_name = null ): ?WP_Parser_Node {
if ( $this->was_mutated ) {
return parent::get_first_descendant_node( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_first_descendant_node( $this, $rule_name );
}
/** @inheritDoc */
public function get_first_descendant_token( ?int $token_id = null ): ?WP_Parser_Token {
if ( $this->was_mutated ) {
return parent::get_first_descendant_token( $token_id );
}
return wp_sqlite_mysql_native_ast_get_first_descendant_token( $this, $token_id );
}
/** @inheritDoc */
public function get_children(): array {
if ( $this->was_mutated ) {
return parent::get_children();
}
return wp_sqlite_mysql_native_ast_get_children( $this );
}
/** @inheritDoc */
public function get_child_nodes( ?string $rule_name = null ): array {
if ( $this->was_mutated ) {
return parent::get_child_nodes( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_child_nodes( $this, $rule_name );
}
/** @inheritDoc */
public function get_child_tokens( ?int $token_id = null ): array {
if ( $this->was_mutated ) {
return parent::get_child_tokens( $token_id );
}
return wp_sqlite_mysql_native_ast_get_child_tokens( $this, $token_id );
}
/** @inheritDoc */
public function get_descendants(): array {
if ( $this->was_mutated ) {
return parent::get_descendants();
}
return wp_sqlite_mysql_native_ast_get_descendants( $this );
}
/** @inheritDoc */
public function get_descendant_nodes( ?string $rule_name = null ): array {
if ( $this->was_mutated ) {
return parent::get_descendant_nodes( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_descendant_nodes( $this, $rule_name );
}
/** @inheritDoc */
public function get_descendant_tokens( ?int $token_id = null ): array {
if ( $this->was_mutated ) {
return parent::get_descendant_tokens( $token_id );
}
return wp_sqlite_mysql_native_ast_get_descendant_tokens( $this, $token_id );
}
/** @inheritDoc */
public function get_start(): int {
if ( $this->was_mutated ) {
return parent::get_start();
}
return wp_sqlite_mysql_native_ast_get_start( $this );
}
/** @inheritDoc */
public function get_length(): int {
if ( $this->was_mutated ) {
return parent::get_length();
}
return wp_sqlite_mysql_native_ast_get_length( $this );
}
private function materialize_native_children(): void {
if ( $this->was_mutated ) {
return;
}
$this->children = wp_sqlite_mysql_native_ast_get_children( $this );
$this->was_mutated = true;
if ( function_exists( 'wp_sqlite_mysql_native_ast_materialize_wrapper' ) ) {
wp_sqlite_mysql_native_ast_materialize_wrapper( $this );
}
}
}
@@ -0,0 +1,14 @@
<?php
/**
* Native-mode public parser entry point.
*
* Always extends the pure-PHP `WP_Parser` so `$parser instanceof WP_Parser`
* keeps working for callers regardless of whether the Rust extension is
* loaded. The actual parsing work is delegated to a composed
* `WP_MySQL_Native_Parser` instance via the `WP_MySQL_Native_Parser_Impl`
* trait — see that file for the per-method delegation.
*/
class WP_MySQL_Parser extends WP_Parser {
use WP_MySQL_Native_Parser_Impl;
}
@@ -0,0 +1,22 @@
<?php
/**
* Bridge helpers for the optional Rust MySQL lexer/parser extension.
* PHP keeps the grammar object, while Rust owns the exported parser state.
*/
/**
* Export grammar internals for the native parser.
*
* @param WP_Parser_Grammar $grammar Parser grammar.
* @return array<string, mixed>
*/
function wp_sqlite_mysql_native_export_grammar( WP_Parser_Grammar $grammar ): array {
return array(
'highest_terminal_id' => $grammar->highest_terminal_id,
'rules' => $grammar->rules,
'lookahead_is_match_possible' => $grammar->lookahead_is_match_possible,
'rule_names' => $grammar->rule_names,
'fragment_ids' => $grammar->fragment_ids,
);
}
@@ -0,0 +1,55 @@
<?php
/**
* Native-mode `WP_MySQL_Parser` implementation, delivered as a trait.
*
* The class that uses this trait (`WP_MySQL_Parser` in native mode)
* extends the pure-PHP `WP_Parser` so callers' `instanceof WP_Parser`
* checks keep working, while the actual parsing work is delegated to
* the Rust-registered `WP_MySQL_Native_Parser` instance held in
* `$this->native`. `WP_Parser`'s state (`$grammar`, `$tokens`,
* `$position`) stays inert in native mode — the trait's overrides
* never read it.
*
* Adding a public method here is enough to plumb a new public method
* through to the native parser; the using class does not need touching.
*/
trait WP_MySQL_Native_Parser_Impl {
/**
* @var WP_MySQL_Native_Parser
*/
private $native;
/**
* @param WP_Parser_Grammar $grammar
* @param array<WP_Parser_Token>|WP_MySQL_Native_Token_Stream $tokens
*/
public function __construct( WP_Parser_Grammar $grammar, $tokens ) {
// WP_Parser's `array $tokens` constructor signature can't accept
// the native token stream object; its `$this->tokens` /
// `$this->position` state is inert in native mode anyway, so we
// pass an empty array to satisfy the parent contract and keep
// the actual tokens on the native parser.
parent::__construct( $grammar, array() );
$this->native = new WP_MySQL_Native_Parser( $grammar, $tokens );
}
/**
* @param array<WP_Parser_Token>|WP_MySQL_Native_Token_Stream $tokens
*/
public function reset_tokens( $tokens ): void {
$this->native->reset_tokens( $tokens );
}
public function next_query(): bool {
return $this->native->next_query();
}
public function get_query_ast(): ?WP_Parser_Node {
return $this->native->get_query_ast();
}
public function parse() {
return $this->native->parse();
}
}
@@ -0,0 +1,140 @@
<?php
/**
* A parser grammar.
*
* This class represents a parser grammar that can be consumed by WP_Parser.
* It loads a compressed grammar from a PHP array, inflates it to an internal
* representation, and precomputes a lookup table for quick branch selection.
*
* @TODO: Add more details about the grammar implementation.
*/
class WP_Parser_Grammar {
/**
* ID for a special grammar rule that represents an empty "ε" (epsilon) rule.
*
* An "ε" rule in a grammar is a rule that matches an empty input of 0 bytes.
* It can be used to represent optional grammar productions, and it is helpful
* for expanding 0-or-1, 0-or-more, and 1-or-more quantifiers into simple rules.
*
* @TODO Investigate whether we can prevent possible conflict with a token ID.
* The MySQL grammar doesn't define a token with ID "0", but generally
* token IDs are not guaranteed to always satisfy this condition.
*/
const EMPTY_RULE_ID = 0;
/**
* @TODO: Review and document these properties and their visibility.
*/
public $rules;
public $rule_names;
public $fragment_ids;
public $lookahead_is_match_possible = array();
public $lowest_non_terminal_id;
public $highest_terminal_id;
public $native_grammar;
public function __construct( array $rules ) {
$this->inflate( $rules );
}
public function get_rule_name( $rule_id ) {
return $this->rule_names[ $rule_id ];
}
public function get_rule_id( $rule_name ) {
return array_search( $rule_name, $this->rule_names, true );
}
/**
* Inflate the grammar to an internal representation optimized for parsing.
*
* The input grammar is a compressed PHP array to minimize the file size.
* Every rule and token in the compressed grammar is encoded as an integer.
*/
private function inflate( $grammar ) {
$this->lowest_non_terminal_id = $grammar['rules_offset'];
$this->highest_terminal_id = $this->lowest_non_terminal_id - 1;
foreach ( $grammar['rules_names'] as $rule_index => $rule_name ) {
$this->rule_names[ $rule_index + $grammar['rules_offset'] ] = $rule_name;
$this->rules[ $rule_index + $grammar['rules_offset'] ] = array();
/**
* Treat all intermediate rules as fragments to inline before returning
* the final parse tree to the API consumer.
*
* The original grammar was too difficult to parse with rules like:
*
* query ::= EOF | ((simpleStatement | beginWork) ((SEMICOLON_SYMBOL EOF?) | EOF))
*
* We've factored rule fragments, such as `EOF?`, into separate rules, such as `%EOF_zero_or_one`.
* This is super useful for parsing, but it limits the API consumer's ability to
* reason about the parse tree.
*
* Fragments are intermediate rules that are not part of the original grammar.
* They are prefixed with a "%" to be distinguished from the original rules.
*/
if ( '%' === $rule_name[0] ) {
$this->fragment_ids[ $rule_index + $grammar['rules_offset'] ] = true;
}
}
$this->rules = array();
foreach ( $grammar['grammar'] as $rule_index => $branches ) {
$rule_id = $rule_index + $grammar['rules_offset'];
$this->rules[ $rule_id ] = $branches;
}
/**
* Compute a rule => [token => true] lookup table for each rule
* that starts with a terminal OR with another rule that already
* has a lookahead mapping.
*
* This is similar to left-factoring the grammar, even if not quite
* the same.
*
* This enables us to quickly bail out from checking branches that
* cannot possibly match the current token. This increased the parser
* speed by a whopping 80%!
*
* @TODO: Explore these possible next steps:
*
* * Compute a rule => [token => branch[]] list lookup table and only
* process the branches that have a chance of matching the current token.
* * Actually left-factor the grammar as much as possible. This, however,
* could inflate the serialized grammar size.
*/
// 5 iterations seem to give us all the speed gains we can get from this.
for ( $i = 0; $i < 5; $i++ ) {
foreach ( $grammar['grammar'] as $rule_index => $branches ) {
$rule_id = $rule_index + $grammar['rules_offset'];
if ( isset( $this->lookahead_is_match_possible[ $rule_id ] ) ) {
continue;
}
$rule_lookup = array();
$first_symbol_can_be_expanded_to_all_terminals = true;
foreach ( $branches as $branch ) {
$terminals = false;
$branch_starts_with_terminal = $branch[0] < $this->lowest_non_terminal_id;
if ( $branch_starts_with_terminal ) {
$terminals = array( $branch[0] );
} elseif ( isset( $this->lookahead_is_match_possible[ $branch[0] ] ) ) {
$terminals = array_keys( $this->lookahead_is_match_possible[ $branch[0] ] );
}
if ( false === $terminals ) {
$first_symbol_can_be_expanded_to_all_terminals = false;
break;
}
foreach ( $terminals as $terminal ) {
$rule_lookup[ $terminal ] = true;
}
}
if ( $first_symbol_can_be_expanded_to_all_terminals ) {
$this->lookahead_is_match_possible[ $rule_id ] = $rule_lookup;
}
}
}
}
}
@@ -0,0 +1,384 @@
<?php
/**
* A node in parse tree.
*
* This class represents a node in the parse tree that is produced by WP_Parser.
* A node corresponds to the related grammar rule that was matched by the parser.
* Each node can contain children, consisting of other nodes and grammar tokens.
* In this way, a parser node constitutes a recursive structure that represents
* a parse (sub)tree at each level of the full grammar tree.
*/
class WP_Parser_Node {
/**
* @TODO: Review and document these properties and their visibility.
*/
public $rule_id;
public $rule_name;
protected $children = array();
public function __construct( $rule_id, $rule_name ) {
$this->rule_id = $rule_id;
$this->rule_name = $rule_name;
}
public function append_child( $node ) {
$this->children[] = $node;
}
/**
* Flatten the matched rule fragments as if their children were direct
* descendants of the current rule.
*
* What are rule fragments?
*
* When we initially parse the grammar file, it has compound rules such
* as this one:
*
* query ::= EOF | ((simpleStatement | beginWork) ((SEMICOLON_SYMBOL EOF?) | EOF))
*
* Building a parser that can understand such rules is way more complex than building
* a parser that only follows simple rules, so we flatten those compound rules into
* simpler ones. The above rule would be flattened to:
*
* query ::= EOF | %query0
* %query0 ::= %%query01 %%query02
* %%query01 ::= simpleStatement | beginWork
* %%query02 ::= SEMICOLON_SYMBOL EOF_zero_or_one | EOF
* EOF_zero_or_one ::= EOF | ε
*
* This factorization happens in "convert-grammar.php".
*
* "Fragments" are intermediate artifacts whose names are not in the original grammar.
* They are extremely useful for the parser, but the API consumer should never have to
* worry about them. Fragment names start with a percent sign ("%").
*
* The code below inlines every fragment back in its parent rule.
*
* We could optimize this. The current $match may be discarded later on so any inlining
* effort here would be wasted. However, inlining seems cheap and doing it bottom-up here
* is **much** easier than reprocessing the parse tree top-down later on.
*
* The following parse tree:
*
* [
* 'query' => [
* [
* '%query01' => [
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ],
* '%query02' => [
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ]
* ],
* ]
* ]
* ]
* ]
* ]
*
* Would be inlined as:
*
* [
* 'query' => [
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ]
* ],
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ]
* ]
* ]
* ]
*/
public function merge_fragment( $node ) {
$this->children = array_merge( $this->children, $node->children );
}
/**
* Check if this node has any child nodes or tokens.
*
* @return bool True if this node has any child nodes or tokens, false otherwise.
*/
public function has_child(): bool {
return count( $this->children ) > 0;
}
/**
* Check if this node has any child nodes.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return bool True if any child nodes are found, false otherwise.
*/
public function has_child_node( ?string $rule_name = null ): bool {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Node
&& ( null === $rule_name || $child->rule_name === $rule_name )
) {
return true;
}
}
return false;
}
/**
* Check if this node has any child tokens.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return bool True if any child tokens are found, false otherwise.
*/
public function has_child_token( ?int $token_id = null ): bool {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Token
&& ( null === $token_id || $child->id === $token_id )
) {
return true;
}
}
return false;
}
/**
* Get the first child node or token of this node.
*
* @return WP_Parser_Node|WP_Parser_Token|null The first child node or token;
* null when no children are found.
*/
public function get_first_child() {
return $this->children[0] ?? null;
}
/**
* Get the first child node of this node.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node|null The first matching child node; null when no children are found.
*/
public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_Node {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Node
&& ( null === $rule_name || $child->rule_name === $rule_name )
) {
return $child;
}
}
return null;
}
/**
* Get the first child token of this node.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token|null The first matching child token; null when no children are found.
*/
public function get_first_child_token( ?int $token_id = null ): ?WP_Parser_Token {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Token
&& ( null === $token_id || $child->id === $token_id )
) {
return $child;
}
}
return null;
}
/**
* Get the first descendant node of this node.
*
* The node children are traversed recursively in a depth-first order until
* a matching descendant node is found, or the entire subtree is searched.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node|null The first matching descendant node; null when no descendants are found.
*/
public function get_first_descendant_node( ?string $rule_name = null ): ?WP_Parser_Node {
for ( $i = 0; $i < count( $this->children ); $i++ ) {
$child = $this->children[ $i ];
if ( ! $child instanceof WP_Parser_Node ) {
continue;
}
if ( null === $rule_name || $child->rule_name === $rule_name ) {
return $child;
}
$node = $child->get_first_descendant_node( $rule_name );
if ( $node ) {
return $node;
}
}
return null;
}
/**
* Get the first descendant token of this node.
*
* The node children are traversed recursively in a depth-first order until
* a matching descendant token is found, or the entire subtree is searched.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token|null The first matching descendant token; null when no descendants are found.
*/
public function get_first_descendant_token( ?int $token_id = null ): ?WP_Parser_Token {
for ( $i = 0; $i < count( $this->children ); $i++ ) {
$child = $this->children[ $i ];
if ( $child instanceof WP_Parser_Token ) {
if ( null === $token_id || $child->id === $token_id ) {
return $child;
}
} else {
$token = $child->get_first_descendant_token( $token_id );
if ( $token ) {
return $token;
}
}
}
return null;
}
/**
* Get all children of this node.
*
* @return array<WP_Parser_Node|WP_Parser_Token> An array of all child nodes and tokens of this node.
*/
public function get_children(): array {
return $this->children;
}
/**
* Get all child nodes of this node.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node[] An array of all matching child nodes.
*/
public function get_child_nodes( ?string $rule_name = null ): array {
$nodes = array();
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Node
&& ( null === $rule_name || $child->rule_name === $rule_name )
) {
$nodes[] = $child;
}
}
return $nodes;
}
/**
* Get all child tokens of this node.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token[] An array of all matching child tokens.
*/
public function get_child_tokens( ?int $token_id = null ): array {
$tokens = array();
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Token
&& ( null === $token_id || $child->id === $token_id )
) {
$tokens[] = $child;
}
}
return $tokens;
}
/**
* Get all descendants of this node.
*
* The descendants are collected using a depth-first pre-order NLR traversal.
* This produces a natural ordering that corresponds to the original input.
*
* @return array<WP_Parser_Node|WP_Parser_Token> An array of all descendant nodes and tokens of this node.
*/
public function get_descendants(): array {
$descendants = array();
foreach ( $this->children as $child ) {
if ( $child instanceof WP_Parser_Node ) {
$descendants[] = $child;
$descendants = array_merge( $descendants, $child->get_descendants() );
} else {
$descendants[] = $child;
}
}
return $descendants;
}
/**
* Get all descendant nodes of this node.
*
* The descendants are collected using a depth-first pre-order NLR traversal.
* This produces a natural ordering that corresponds to the original input.
* All matching nodes are collected during the traversal.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node[] An array of all matching descendant nodes.
*/
public function get_descendant_nodes( ?string $rule_name = null ): array {
$nodes = array();
foreach ( $this->children as $child ) {
if ( ! $child instanceof WP_Parser_Node ) {
continue;
}
if ( null === $rule_name || $child->rule_name === $rule_name ) {
$nodes[] = $child;
}
$nodes = array_merge( $nodes, $child->get_descendant_nodes( $rule_name ) );
}
return $nodes;
}
/**
* Get all descendant tokens of this node.
*
* The descendants are collected using a depth-first pre-order NLR traversal.
* This produces a natural ordering that corresponds to the original input.
* All matching tokens are collected during the traversal.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token[] An array of all matching descendant tokens.
*/
public function get_descendant_tokens( ?int $token_id = null ): array {
$tokens = array();
foreach ( $this->children as $child ) {
if ( $child instanceof WP_Parser_Token ) {
if ( null === $token_id || $child->id === $token_id ) {
$tokens[] = $child;
}
} else {
$tokens = array_merge( $tokens, $child->get_descendant_tokens( $token_id ) );
}
}
return $tokens;
}
/**
* Get the byte offset in the input string where this node begins.
*
* @return int The byte offset in the input string where this node begins.
*/
public function get_start(): int {
return $this->get_first_descendant_token()->start;
}
/**
* Get the byte length of this node in the input string.
*
* @return int The byte length of this node in the input string.
*/
public function get_length(): int {
$tokens = $this->get_descendant_tokens();
$first_token = $tokens[0];
$last_token = $tokens[ count( $tokens ) - 1 ];
return $last_token->start + $last_token->length - $first_token->start;
}
}
@@ -0,0 +1,77 @@
<?php
/**
* A token, representing a leaf in the parse tree.
*
* This class represents a token that is consumed and recognized by WP_Parser.
* In a parse tree, a token represent a leaf, that is, a node without children.
* It is a simple generic container for a token ID and value, that can be used
* as a base class and extended for specific use cases.
*/
class WP_Parser_Token {
/**
* Token ID represented as an integer constant.
*
* @var int $id
*/
public $id;
/**
* Byte offset in the input where the token begins.
*
* @var int
*/
public $start;
/**
* Byte length of the token in the input.
*
* @var int
*/
public $length;
/**
* Input bytes from which the token was parsed.
*
* @var string
*/
protected $input;
/**
* Constructor.
*
* @param int $id Token type.
* @param int $start Byte offset in the input where the token begins.
* @param int $length Byte length of the token in the input.
* @param string $input Input bytes from which the token was parsed.
*/
public function __construct(
int $id,
int $start,
int $length,
string $input
) {
$this->id = $id;
$this->start = $start;
$this->length = $length;
$this->input = $input;
}
/**
* Get the raw bytes of the token from the input.
*
* @return string The token bytes.
*/
public function get_bytes(): string {
return substr( $this->input, $this->start, $this->length );
}
/**
* Get the real unquoted value of the token.
*
* @return string The token value.
*/
public function get_value(): string {
return $this->get_bytes();
}
}
@@ -0,0 +1,124 @@
<?php
/**
* A recursive descent parser.
*
* This is a dynamic recursive descent parser that can parse LL grammars.
*
* @TODO: Add a detailed description and list the properties that a grammar must
* satisfy in order to be supported by this parser (e.g., no left recursion).
*/
class WP_Parser {
protected $grammar;
protected $tokens;
protected $position;
public function __construct( WP_Parser_Grammar $grammar, array $tokens ) {
$this->grammar = $grammar;
$this->tokens = $tokens;
$this->position = 0;
}
public function parse() {
// @TODO: Make the starting rule lookup non-grammar-specific.
$query_rule_id = $this->grammar->get_rule_id( 'query' );
$ast = $this->parse_recursive( $query_rule_id );
return false === $ast ? null : $ast;
}
private function parse_recursive( $rule_id ) {
$is_terminal = $rule_id <= $this->grammar->highest_terminal_id;
if ( $is_terminal ) {
if ( $this->position >= count( $this->tokens ) ) {
return false;
}
if ( WP_Parser_Grammar::EMPTY_RULE_ID === $rule_id ) {
return true;
}
if ( $this->tokens[ $this->position ]->id === $rule_id ) {
++$this->position;
return $this->tokens[ $this->position - 1 ];
}
return false;
}
$branches = $this->grammar->rules[ $rule_id ];
if ( ! count( $branches ) ) {
return false;
}
// Bale out from processing the current branch if none of its rules can
// possibly match the current token.
if ( isset( $this->grammar->lookahead_is_match_possible[ $rule_id ] ) ) {
$token_id = $this->tokens[ $this->position ]->id;
if (
! isset( $this->grammar->lookahead_is_match_possible[ $rule_id ][ $token_id ] ) &&
! isset( $this->grammar->lookahead_is_match_possible[ $rule_id ][ WP_Parser_Grammar::EMPTY_RULE_ID ] )
) {
return false;
}
}
$rule_name = $this->grammar->rule_names[ $rule_id ];
$starting_position = $this->position;
foreach ( $branches as $branch ) {
$this->position = $starting_position;
$node = new WP_Parser_Node( $rule_id, $rule_name );
$branch_matches = true;
foreach ( $branch as $subrule_id ) {
$subnode = $this->parse_recursive( $subrule_id );
if ( false === $subnode ) {
$branch_matches = false;
break;
} elseif ( true === $subnode ) {
/*
* The subrule was matched without actually matching a token.
* This means a special empty "ε" (epsilon) rule was matched.
* An "ε" rule in a grammar matches an empty input of 0 bytes.
* It is used to represent optional grammar productions.
*/
continue;
} elseif ( is_array( $subnode ) && 0 === count( $subnode ) ) {
continue;
}
if ( is_array( $subnode ) && ! count( $subnode ) ) {
continue;
}
if ( isset( $this->grammar->fragment_ids[ $subrule_id ] ) ) {
$node->merge_fragment( $subnode );
} else {
$node->append_child( $subnode );
}
}
// Negative lookahead for INTO after a valid SELECT statement.
// If we match a SELECT statement, but there is an INTO keyword after it,
// we're in the wrong branch and need to leave matching to a later rule.
// @TODO: Extract this to the "WP_MySQL_Parser" class, or add support
// for right-associative rules, which could solve this.
// See: https://github.com/mysql/mysql-workbench/blob/8.0.38/library/parsers/grammars/MySQLParser.g4#L994
// See: https://github.com/antlr/antlr4/issues/488
$la = $this->tokens[ $this->position ] ?? null;
if ( $la && 'selectStatement' === $rule_name && WP_MySQL_Lexer::INTO_SYMBOL === $la->id ) {
$branch_matches = false;
}
if ( true === $branch_matches ) {
break;
}
}
if ( ! $branch_matches ) {
$this->position = $starting_position;
return false;
}
if ( ! $node->has_child() ) {
return true;
}
return $node;
}
}
@@ -0,0 +1,68 @@
<?php
/**
* Polyfills for PHP 8.0 string functions.
*
* Implementation follows the Symfony polyfill-php80 package.
*
* @see https://github.com/symfony/polyfill-php80
*
* @package wp-sqlite-integration
*/
if ( ! function_exists( 'str_starts_with' ) ) {
/**
* Check if a string starts with a specific substring.
*
* @param string $haystack The string to search in.
* @param string $needle The string to search for.
*
* @see https://www.php.net/manual/en/function.str-starts-with
*
* @return bool
*/
function str_starts_with( string $haystack, string $needle ) {
return 0 === strncmp( $haystack, $needle, strlen( $needle ) );
}
}
if ( ! function_exists( 'str_contains' ) ) {
/**
* Check if a string contains a specific substring.
*
* @param string $haystack The string to search in.
* @param string $needle The string to search for.
*
* @see https://www.php.net/manual/en/function.str-contains
*
* @return bool
*/
function str_contains( string $haystack, string $needle ) {
return '' === $needle || false !== strpos( $haystack, $needle );
}
}
if ( ! function_exists( 'str_ends_with' ) ) {
/**
* Check if a string ends with a specific substring.
*
* @param string $haystack The string to search in.
* @param string $needle The string to search for.
*
* @see https://www.php.net/manual/en/function.str-ends-with
*
* @return bool
*/
function str_ends_with( string $haystack, string $needle ) {
if ( '' === $needle || $needle === $haystack ) {
return true;
}
if ( '' === $haystack ) {
return false;
}
$needle_length = strlen( $needle );
return $needle_length <= strlen( $haystack ) && 0 === substr_compare( $haystack, $needle, -$needle_length );
}
}
@@ -0,0 +1,382 @@
<?php
/*
* The SQLite driver uses PDO. Enable PDO function calls:
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDOStatement
*
* PDO uses camel case naming, enable non-snake case:
* phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
* phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
*
* PDO uses $class as a variable name, enable it:
* phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.classFound
*
* Some PDOStatement methods use $var as a variable name, enable it:
* phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.varFound
*
* We use traits to support different PHP versions with incompatible PDO statement
* method signatures. For that, enable multiple object structures in one file:
* phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
*/
/**
* Some PDOStatement methods are not compatible across different PHP versions.
* To address "Declaration of ... should be compatible with ..." PHP warnings,
* we conditionally define traits with different APIs based on the PHP version.
*/
if ( PHP_VERSION_ID < 80000 ) {
trait WP_PDO_Proxy_Statement_PHP_Compat {
/**
* Set the default fetch mode for this statement.
*
* @param int $mode The fetch mode to set as the default.
* @param mixed $params Additional parameters for the default fetch mode.
* @return bool True on success, false on failure.
*/
public function setFetchMode( $mode, $params = null ): bool {
// Do not pass additional arguments when they are NULL to prevent
// "fetch mode doesn't allow any extra arguments" error.
if ( null === $params ) {
return $this->setDefaultFetchMode( $mode );
}
return $this->setDefaultFetchMode( $mode, $params );
}
/**
* Fetch all remaining rows from the result set.
*
* @param int $mode The fetch mode to use.
* @param mixed $class_name With PDO::FETCH_CLASS, the name of the class to instantiate.
* @param mixed $constructor_args With PDO::FETCH_CLASS, the parameters to pass to the class constructor.
* @return array The result set as an array of rows.
*/
public function fetchAll( $mode = null, $class_name = null, $constructor_args = null ): array {
// Do not pass additional arguments when they are NULL to prevent
// "Extraneous additional parameters" error.
if ( null === $class_name && null === $constructor_args ) {
return $this->fetchAllRows( $mode );
}
return $this->fetchAllRows( $mode, $class_name, $constructor_args );
}
}
} else {
trait WP_PDO_Proxy_Statement_PHP_Compat {
/**
* Set the default fetch mode for this statement.
*
* @param int $mode The fetch mode to set as the default.
* @param mixed $args Additional parameters for the default fetch mode.
* @return bool True on success, false on failure.
*/
#[ReturnTypeWillChange]
public function setFetchMode( $mode, ...$args ): bool {
return $this->setDefaultFetchMode( $mode, ...$args );
}
/**
* Fetch all remaining rows from the result set.
*
* @param int $mode The fetch mode to use.
* @param mixed $args Additional parameters for the fetch mode.
* @return array The result set as an array of rows.
*/
public function fetchAll( $mode = PDO::FETCH_DEFAULT, ...$args ): array {
return $this->fetchAllRows( $mode, ...$args );
}
}
}
/**
* PDOStatement implementation that operates on in-memory data.
*
* This class implements a complete PDOStatement interface on top of PHP arrays.
* It is used for result sets that are composed or transformed in the PHP layer.
*
* PDO supports the following fetch modes:
* - PDO::FETCH_DEFAULT: current default fetch mode (available from PHP 8.0)
* - PDO::FETCH_BOTH: default
* - PDO::FETCH_NUM: numeric array
* - PDO::FETCH_ASSOC: associative array
* - PDO::FETCH_NAMED: associative array retaining duplicate columns
* - PDO::FETCH_COLUMN: single column value [1 extra arg]
* - PDO::FETCH_KEY_PAIR: key-value pair
* - PDO::FETCH_OBJ: object (stdClass)
* - PDO::FETCH_CLASS: object (custom class) [1-2 extra args]
* - PDO::FETCH_INTO: update an exisisting object, can't be used with fetchAll() [1 extra arg]
* - PDO::FETCH_LAZY: lazy fetch via PDORow, can't be used with fetchAll()
* - PDO::FETCH_BOUND: bind values to PHP variables, can't be used with fetchAll()
* - PDO::FETCH_FUNC: custom function, only works with fetchAll(), can't be default [1 extra arg]
*/
class WP_PDO_Proxy_Statement extends PDOStatement {
use WP_PDO_Proxy_Statement_PHP_Compat;
/**
* The original PDO statement.
*
* @var PDOStatement
*/
private $statement;
/**
* The number of affected rows.
*
* @var int|null
*/
private $affected_rows;
/**
* Constructor.
*
* @param PDOStatement $statement The original PDO statement.
* @param int $affected_rows The number of affected rows.
*/
public function __construct(
PDOStatement $statement,
?int $affected_rows = null
) {
$this->statement = $statement;
$this->affected_rows = $affected_rows;
}
/**
* Execute a prepared statement.
*
* @param mixed $params The values to bind to the parameters of the prepared statement.
* @return bool True on success, false on failure.
*/
public function execute( $params = null ): bool {
return $this->statement->execute( $params );
}
/**
* Get the number of columns in the result set.
*
* @return int The number of columns in the result set.
*/
public function columnCount(): int {
return $this->statement->columnCount();
}
/**
* Get the number of rows affected by the statement.
*
* @return int The number of rows affected by the statement.
*/
public function rowCount(): int {
return $this->affected_rows ?? $this->statement->rowCount();
}
/**
* Fetch the next row from the result set.
*
* @param int|null $mode The fetch mode. Controls how the row is returned.
* Default: PDO::FETCH_DEFAULT (null for PHP < 8.0)
* @param int|null $cursorOrientation The cursor orientation. Controls which row is returned.
* Default: PDO::FETCH_ORI_NEXT (null for PHP < 8.0)
* @param int|null $cursorOffset The cursor offset. Controls which row is returned.
* Default: 0 (null for PHP < 8.0)
* @return mixed The row data formatted according to the fetch mode;
* false if there are no more rows or a failure occurs.
*/
#[ReturnTypeWillChange]
public function fetch(
$mode = 0, // PDO::FETCH_DEFAULT (available from PHP 8.0)
$cursorOrientation = 0,
$cursorOffset = 0
) {
return $this->statement->fetch( $mode, $cursorOrientation, $cursorOffset );
}
/**
* Fetch a single column from the next row of a result set.
*
* @param int $column The index of the column to fetch (0-indexed).
* @return mixed The value of the column; false if there are no more rows.
*/
#[ReturnTypeWillChange]
public function fetchColumn( $column = 0 ) {
return $this->statement->fetchColumn( $column );
}
/**
* Fetch the next row as an object.
*
* @param string $class The name of the class to instantiate.
* @param array $constructorArgs The parameters to pass to the class constructor.
* @return object The next row as an object.
*/
#[ReturnTypeWillChange]
public function fetchObject( $class = 'stdClass', $constructorArgs = array() ) {
return $this->statement->fetchObject( $class, $constructorArgs );
}
/**
* Get metadata for a column in a result set.
*
* @param int $column The index of the column (0-indexed).
* @return array|false The column metadata as an associative array,
* or false if the column does not exist.
*/
public function getColumnMeta( $column ): array {
throw new RuntimeException( 'Not implemented' );
}
/**
* Fetch the SQLSTATE associated with the last statement operation.
*
* @return string|null The SQLSTATE error code (as defined by the ANSI SQL standard),
* or null if there is no error.
*/
public function errorCode(): ?string {
throw new RuntimeException( 'Not implemented' );
}
/**
* Fetch error information associated with the last statement operation.
*
* @return array The array consists of at least the following fields:
* 0: SQLSTATE error code (as defined by the ANSI SQL standard).
* 1: Driver-specific error code.
* 2: Driver-specific error message.
*/
public function errorInfo(): array {
throw new RuntimeException( 'Not implemented' );
}
/**
* Get a statement attribute.
*
* @param int $attribute The attribute to get.
* @return mixed The value of the attribute.
*/
#[ReturnTypeWillChange]
public function getAttribute( $attribute ) {
return $this->statement->getAttribute( $attribute );
}
/**
* Set a statement attribute.
*
* @param int $attribute The attribute to set.
* @param mixed $value The value of the attribute.
* @return bool True on success, false on failure.
*/
public function setAttribute( $attribute, $value ): bool {
return $this->statement->setAttribute( $attribute, $value );
}
/**
* Get result set as iterator.
*
* @return Iterator The iterator for the result set.
*/
public function getIterator(): Iterator {
throw new RuntimeException( 'Not implemented' );
}
/**
* Advances to the next rowset in a multi-rowset statement handle.
*
* @return bool True on success, false on failure.
*/
public function nextRowset(): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Closes the cursor, enabling the statement to be executed again.
*
* @return bool True on success, false on failure.
*/
public function closeCursor(): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Bind a column to a PHP variable.
*
* @param int|string $column Number of the column (1-indexed) or name of the column in the result set.
* @param mixed $var PHP variable to which the column will be bound.
* @param int $type Data type of the parameter, specified by the PDO::PARAM_* constants.
* @param int $maxLength A hint for pre-allocation.
* @param mixed $driverOptions Optional parameters for the driver.
* @return bool True on success, false on failure.
*/
public function bindColumn( $column, &$var, $type = null, $maxLength = null, $driverOptions = null ): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Bind a parameter to a PHP variable.
*
* @param int|string $param Parameter identifier. Either a 1-indexed position of the parameter or a named parameter.
* @param mixed $var PHP variable to which the parameter will be bound.
* @param int $type Data type of the parameter, specified by the PDO::PARAM_* constants.
* @param int $maxLength Length of the data type.
* @param mixed $driverOptions Optional parameters for the driver.
* @return bool True on success, false on failure.
*/
public function bindParam( $param, &$var, $type = PDO::PARAM_STR, $maxLength = 0, $driverOptions = null ): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Bind a value to a parameter.
*
* @param int|string $param Parameter identifier. Either a 1-indexed position of the parameter or a named parameter.
* @param mixed $value The value to bind to the parameter.
* @param int $type Data type of the parameter, specified by the PDO::PARAM_* constants.
* @return bool True on success, false on failure.
*/
public function bindValue( $param, $value, $type = PDO::PARAM_STR ): bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Dump information about the statement.
*
* Dupms the SQL query and parameters information.
*
* @return bool|null Returns null, or false on failure.
*/
public function debugDumpParams(): ?bool {
throw new RuntimeException( 'Not implemented' );
}
/**
* Fetch all remaining rows from the result set.
*
* This is used internally by the "WP_PDO_Proxy_Statement_PHP_Compat" trait,
* that is defined conditionally based on the current PHP version.
*
* @param int $mode The fetch mode to use.
* @param mixed $args Additional parameters for the fetch mode.
* @return array The result set as an array of rows.
*/
private function fetchAllRows( $mode = null, ...$args ): array {
return $this->statement->fetchAll( $mode, ...$args );
}
/**
* Set the default fetch mode for this statement.
*
* This is used internally by the "WP_PDO_Proxy_Statement_PHP_Compat" trait,
* that is defined conditionally based on the current PHP version.
*
* @param int $mode The fetch mode to set as the default.
* @param mixed $args Additional parameters for the default fetch mode.
* @return bool True on success, false on failure.
*/
private function setDefaultFetchMode( $mode, ...$args ): bool {
return $this->statement->setFetchMode( $mode, ...$args );
}
}
/**
* Polyfill ValueError for PHP < 8.0.
*/
if ( PHP_VERSION_ID < 80000 && ! class_exists( ValueError::class ) ) {
class ValueError extends Error {
}
}
@@ -0,0 +1,273 @@
<?php
/**
* SQLite database configurator.
*
* This class initializes and configures the SQLite database, so that it can be
* used by the SQLite driver to translate and emulate MySQL queries in SQLite.
*
* The configurator ensures that tables required for emulating MySQL behaviors
* are created and populated with necessary data. It is also able to partially
* repair and update these tables and metadata in case of database corruption.
*/
class WP_SQLite_Configurator {
/**
* The SQLite driver instance.
*
* @var WP_PDO_MySQL_On_SQLite
*/
private $driver;
/**
* A service for managing MySQL INFORMATION_SCHEMA tables in SQLite.
*
* @var WP_SQLite_Information_Schema_Builder
*/
private $schema_builder;
/**
* A service for reconstructing the MySQL INFORMATION_SCHEMA tables in SQLite.
*
* @var WP_SQLite_Information_Schema_Reconstructor
*/
private $schema_reconstructor;
/**
* Constructor.
*
* @param WP_PDO_MySQL_On_SQLite $driver The SQLite driver instance.
* @param WP_SQLite_Information_Schema_Builder $schema_builder The information schema builder instance.
*/
public function __construct(
WP_PDO_MySQL_On_SQLite $driver,
WP_SQLite_Information_Schema_Builder $schema_builder
) {
$this->driver = $driver;
$this->schema_builder = $schema_builder;
$this->schema_reconstructor = new WP_SQLite_Information_Schema_Reconstructor(
$driver,
$schema_builder
);
}
/**
* Ensure that the SQLite database is configured.
*
* This method checks if the database is configured for the latest SQLite
* driver version, and if it is not, it will configure the database.
*/
public function ensure_database_configured(): void {
$version = SQLITE_DRIVER_VERSION;
$db_version = $this->driver->get_saved_driver_version();
if ( version_compare( $version, $db_version ) > 0 ) {
$this->configure_database();
}
}
/**
* Configure the SQLite database.
*
* This method creates tables used for emulating MySQL behaviors in SQLite,
* and populates them with necessary data. When it is used with an already
* configured database, it will update the configuration as per the current
* SQLite driver version and attempt to repair any configuration corruption.
*/
public function configure_database(): void {
// Use an EXCLUSIVE transaction to prevent multiple connections
// from attempting to configure the database at the same time.
$this->driver->execute_sqlite_query( 'BEGIN EXCLUSIVE TRANSACTION' );
try {
$this->ensure_global_variables_table();
$this->schema_builder->ensure_information_schema_tables();
$this->schema_reconstructor->ensure_correct_information_schema();
$this->save_current_driver_version();
$this->ensure_database_data();
} catch ( Throwable $e ) {
$this->driver->execute_sqlite_query( 'ROLLBACK' );
throw $e;
}
$this->driver->execute_sqlite_query( 'COMMIT' );
}
/**
* Ensure that the global variables table exists.
*
* This method configures a database table to store MySQL global variables
* and other internal configuration values.
*/
private function ensure_global_variables_table(): void {
$this->driver->execute_sqlite_query(
sprintf(
'CREATE TABLE IF NOT EXISTS %s (name TEXT PRIMARY KEY, value TEXT)',
$this->driver->get_connection()->quote_identifier(
WP_PDO_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME
)
)
);
}
/**
* Ensure that the database data is correctly populated.
*
* This method ensures that the "INFORMATION_SCHEMA.SCHEMATA" table contains
* records for both the "INFORMATION_SCHEMA" database and the user database.
* At the moment, only a single user database is supported.
*
* Additionally, this method ensures that the user database name is stored
* correctly in all the information schema tables.
*/
public function ensure_database_data(): void {
// Get all databases from the "SCHEMATA" table.
$schemata_table = $this->schema_builder->get_table_name( false, 'schemata' );
$databases = $this->driver->execute_sqlite_query(
sprintf(
'SELECT SCHEMA_NAME FROM %s',
$this->driver->get_connection()->quote_identifier( $schemata_table )
)
)->fetchAll( PDO::FETCH_COLUMN ); // phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
// Ensure that the "INFORMATION_SCHEMA" database record exists.
if ( ! in_array( 'information_schema', $databases, true ) ) {
$this->driver->execute_sqlite_query(
sprintf(
'INSERT INTO %s (SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME) VALUES (?, ?, ?)',
$this->driver->get_connection()->quote_identifier( $schemata_table )
),
// The "INFORMATION_SCHEMA" database stays on "utf8mb3" even in MySQL 8 and 9.
array( 'information_schema', 'utf8mb3', 'utf8mb3_general_ci' )
);
}
// Get the existing user database name.
$existing_user_db_name = null;
foreach ( $databases as $database ) {
if ( 'information_schema' !== strtolower( $database ) ) {
$existing_user_db_name = $database;
break;
}
}
// Ensure that the user database record exists.
if ( null === $existing_user_db_name ) {
$existing_user_db_name = WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME;
$this->driver->execute_sqlite_query(
sprintf(
'INSERT INTO %s (SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME) VALUES (?, ?, ?)',
$this->driver->get_connection()->quote_identifier( $schemata_table )
),
// @TODO: This should probably be version-dependent.
// Before MySQL 8, the default was different.
array( $existing_user_db_name, 'utf8mb4', 'utf8mb4_0900_ai_ci' )
);
}
// Migrate from older versions without dynamic database names.
$saved_database_name = WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME;
if ( $saved_database_name !== $existing_user_db_name ) {
// INFORMATION_SCHEMA.SCHEMATA
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET SCHEMA_NAME = ? WHERE SCHEMA_NAME != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $schemata_table )
),
array( $saved_database_name )
);
// INFORMATION_SCHEMA.TABLES
$tables_table = $this->schema_builder->get_table_name( false, 'tables' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET TABLE_SCHEMA = ? WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $tables_table )
),
array( $saved_database_name )
);
// INFORMATION_SCHEMA.COLUMNS
$columns_table = $this->schema_builder->get_table_name( false, 'columns' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET TABLE_SCHEMA = ? WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $columns_table )
),
array( $saved_database_name )
);
// INFORMATION_SCHEMA.STATISTICS
$statistics_table = $this->schema_builder->get_table_name( false, 'statistics' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET TABLE_SCHEMA = ?, INDEX_SCHEMA = ? WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $statistics_table )
),
array( $saved_database_name, $saved_database_name )
);
// INFORMATION_SCHEMA.TABLE_CONSTRAINTS
$table_constraints_table = $this->schema_builder->get_table_name( false, 'table_constraints' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET TABLE_SCHEMA = ?, CONSTRAINT_SCHEMA = ? WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $table_constraints_table )
),
array( $saved_database_name, $saved_database_name )
);
// INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
$referential_constraints_table = $this->schema_builder->get_table_name( false, 'referential_constraints' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET CONSTRAINT_SCHEMA = ?, UNIQUE_CONSTRAINT_SCHEMA = ? WHERE CONSTRAINT_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $referential_constraints_table )
),
array( $saved_database_name, $saved_database_name )
);
// INFORMATION_SCHEMA.KEY_COLUMN_USAGE
$key_column_usage_table = $this->schema_builder->get_table_name( false, 'key_column_usage' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s
SET
TABLE_SCHEMA = ?,
CONSTRAINT_SCHEMA = ?,
REFERENCED_TABLE_SCHEMA = CASE WHEN REFERENCED_TABLE_SCHEMA IS NULL THEN NULL ELSE ? END
WHERE TABLE_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $key_column_usage_table )
),
array( $saved_database_name, $saved_database_name, $saved_database_name )
);
// INFORMATION_SCHEMA.CHECK_CONSTRAINTS
$check_constraints_table = $this->schema_builder->get_table_name( false, 'check_constraints' );
$this->driver->execute_sqlite_query(
sprintf(
"UPDATE %s SET CONSTRAINT_SCHEMA = ? WHERE CONSTRAINT_SCHEMA != 'information_schema'",
$this->driver->get_connection()->quote_identifier( $check_constraints_table )
),
array( $saved_database_name )
);
}
}
/**
* Save the current SQLite driver version.
*
* This method saves the current SQLite driver version to the database.
*/
private function save_current_driver_version(): void {
$this->driver->execute_sqlite_query(
sprintf(
'INSERT INTO %s (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = ?',
$this->driver->get_connection()->quote_identifier(
WP_PDO_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME
)
),
array(
WP_PDO_MySQL_On_SQLite::DRIVER_VERSION_VARIABLE_NAME,
SQLITE_DRIVER_VERSION,
SQLITE_DRIVER_VERSION,
)
);
}
}
@@ -0,0 +1,210 @@
<?php declare(strict_types = 1);
/*
* The SQLite connection uses PDO. Enable PDO function calls:
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
*/
/**
* SQLite connection.
*
* This class configures and encapsulates the connection to an SQLite database.
* It requires PDO with the SQLite driver, and currently, it is only a simple
* wrapper that leaks some of the PDO APIs (returns PDOStatement values, etc.).
* In the future, we may abstract it away from PDO and support SQLite3 as well.
*/
class WP_SQLite_Connection {
/**
* The default timeout in seconds for SQLite to wait for a writable lock.
*/
const DEFAULT_SQLITE_TIMEOUT = 10;
/**
* The supported SQLite journal modes.
*
* See: https://www.sqlite.org/pragma.html#pragma_journal_mode
*/
const SQLITE_JOURNAL_MODES = array(
'DELETE',
'TRUNCATE',
'PERSIST',
'MEMORY',
'WAL',
'OFF',
);
/**
* The PDO connection for SQLite.
*
* @var PDO
*/
private $pdo;
/**
* A query logger callback.
*
* @var callable(string, array): void
*/
private $query_logger;
/**
* Constructor.
*
* Set up an SQLite connection.
*
* @param array $options {
* An array of options.
*
* @type string|null $path Optional. SQLite database path.
* For in-memory database, use ':memory:'.
* Must be set when PDO instance is not provided.
* @type PDO|null $pdo Optional. PDO instance with SQLite connection.
* If not provided, a new PDO instance will be created.
* @type int|null $timeout Optional. SQLite timeout in seconds.
* The time to wait for a writable lock.
* @type string|null $journal_mode Optional. SQLite journal mode.
* }
*
* @throws InvalidArgumentException When some connection options are invalid.
* @throws PDOException When the driver initialization fails.
*/
public function __construct( array $options ) {
// Setup PDO connection.
if ( isset( $options['pdo'] ) && $options['pdo'] instanceof PDO ) {
$this->pdo = $options['pdo'];
} else {
if ( ! isset( $options['path'] ) || ! is_string( $options['path'] ) ) {
throw new InvalidArgumentException( 'Option "path" is required when "connection" is not provided.' );
}
$pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class;
$this->pdo = new $pdo_class( 'sqlite:' . $options['path'] );
}
// Throw exceptions on error.
$this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
// Configure SQLite timeout.
if ( isset( $options['timeout'] ) && is_int( $options['timeout'] ) ) {
$timeout = $options['timeout'];
} else {
$timeout = self::DEFAULT_SQLITE_TIMEOUT;
}
$this->pdo->setAttribute( PDO::ATTR_TIMEOUT, $timeout );
// Configure SQLite journal mode.
$journal_mode = $options['journal_mode'] ?? null;
if ( $journal_mode && in_array( $journal_mode, self::SQLITE_JOURNAL_MODES, true ) ) {
$this->query( 'PRAGMA journal_mode = ' . $journal_mode );
}
}
/**
* Execute a query in SQLite.
*
* @param string $sql The query to execute.
* @param array $params The query parameters.
* @throws PDOException When the query execution fails.
* @return PDOStatement The PDO statement object.
*/
public function query( string $sql, array $params = array() ): PDOStatement {
if ( $this->query_logger ) {
( $this->query_logger )( $sql, $params );
}
$stmt = $this->pdo->prepare( $sql );
$stmt->execute( $params );
return $stmt;
}
/**
* Prepare a SQLite query for execution.
*
* @param string $sql The query to prepare.
* @return PDOStatement The prepared statement.
* @throws PDOException When the query preparation fails.
*/
public function prepare( string $sql ): PDOStatement {
if ( $this->query_logger ) {
( $this->query_logger )( $sql, array() );
}
return $this->pdo->prepare( $sql );
}
/**
* Returns the ID of the last inserted row.
*
* @return string The ID of the last inserted row.
*/
public function get_last_insert_id(): string {
return $this->pdo->lastInsertId();
}
/**
* Quote a value for use in a query.
*
* @param mixed $value The value to quote.
* @param int $type The type of the value.
* @return string The quoted value.
*/
public function quote( $value, int $type = PDO::PARAM_STR ): string {
return $this->pdo->quote( $value, $type );
}
/**
* Quote an SQLite identifier.
*
* Wraps the identifier in backticks and escapes backtick characters within.
*
* ---
*
* Quoted identifiers in SQLite are represented by string constants:
*
* A string constant is formed by enclosing the string in single quotes (').
* A single quote within the string can be encoded by putting two single
* quotes in a row - as in Pascal. C-style escapes using the backslash
* character are not supported because they are not standard SQL.
*
* See: https://www.sqlite.org/lang_expr.html#literal_values_constants_
*
* Although sparsely documented, this applies to backtick and double quoted
* string constants as well, so only the quote character needs to be escaped.
*
* For more details, see the grammar for SQLite table and column names:
*
* - https://github.com/sqlite/sqlite/blob/873fc5dff2a781251f2c9bd2c791a5fac45b7a2b/src/tokenize.c#L395-L419
* - https://github.com/sqlite/sqlite/blob/873fc5dff2a781251f2c9bd2c791a5fac45b7a2b/src/parse.y#L321-L338
*
* ---
*
* We use backtick quotes instead of the SQL standard double quotes, due to
* an SQLite quirk causing double-quoted strings to be accepted as literals:
*
* This misfeature means that a misspelled double-quoted identifier will
* be interpreted as a string literal, rather than generating an error.
*
* See: https://www.sqlite.org/quirks.html#double_quoted_string_literals_are_accepted
*
* @param string $unquoted_identifier The unquoted identifier value.
* @return string The quoted identifier value.
*/
public function quote_identifier( string $unquoted_identifier ): string {
return '`' . str_replace( '`', '``', $unquoted_identifier ) . '`';
}
/**
* Get the PDO object.
*
* @return PDO
*/
public function get_pdo(): PDO {
return $this->pdo;
}
/**
* Set a logger for the queries.
*
* @param callable(string, array): void $logger A query logger callback.
*/
public function set_query_logger( callable $logger ): void {
$this->query_logger = $logger;
}
}
@@ -0,0 +1,33 @@
<?php
class WP_SQLite_Driver_Exception extends PDOException {
/**
* The SQLite driver that originated the exception.
*
* @var WP_PDO_MySQL_On_SQLite
*/
private $driver;
/**
* Constructor.
*
* @param WP_PDO_MySQL_On_SQLite $driver The SQLite driver that originated the exception.
* @param string $message The exception message.
* @param int|string $code The exception code. In PDO, it can be a string with value of SQLSTATE.
* @param Throwable|null $previous The previous throwable used for the exception chaining.
*/
public function __construct(
WP_PDO_MySQL_On_SQLite $driver,
string $message,
$code = 0,
?Throwable $previous = null
) {
parent::__construct( $message, 0, $previous );
$this->code = $code;
$this->driver = $driver;
}
public function getDriver(): WP_PDO_MySQL_On_SQLite {
return $this->driver;
}
}
@@ -0,0 +1,279 @@
<?php
/*
* The SQLite driver uses PDO. Enable PDO function calls:
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
*/
/**
* For back compatibility with dependencies that use their own loader scripts
* (e.g., WP CLI SQLite Command), ensure the new PDO-based classes are loaded.
*/
require_once __DIR__ . '/class-wp-pdo-mysql-on-sqlite.php';
require_once __DIR__ . '/class-wp-pdo-proxy-statement.php';
/**
* Deprecated: A proxy of the WP_PDO_MySQL_On_SQLite class preserving legacy API.
*
* This is a temporary class to preserve the legacy API for easier transition
* to the new PDO-based API, developed in the "WP_PDO_MySQL_On_SQLite" class.
*/
class WP_SQLite_Driver {
/**
* The SQLite engine version.
*
* This is a mysqli-like property that is needed to avoid a PHP warning in
* the WordPress health info. The "WP_Debug_Data::get_wp_database()" method
* calls "$wpdb->dbh->client_info" - a mysqli-specific abstraction leak.
*
* @TODO: This should be fixed in WordPress core.
*
* See:
* https://github.com/WordPress/wordpress-develop/blob/bcdca3f9925f1d3eca7b78d231837c0caf0c8c24/src/wp-admin/includes/class-wp-debug-data.php#L1579
*
* @var string
*/
public $client_info;
/**
* The MySQL-on-SQLite driver instance.
*
* @var WP_PDO_MySQL_On_SQLite
*/
private $mysql_on_sqlite_driver;
/**
* Results of the last emulated query.
*
* @var mixed
*/
private $last_result;
/**
* Constructor.
*
* Set up an SQLite connection and the MySQL-on-SQLite driver.
*
* @param WP_SQLite_Connection $connection A SQLite database connection.
* @param string $database The database name.
*
* @throws WP_SQLite_Driver_Exception When the driver initialization fails.
*/
public function __construct(
WP_SQLite_Connection $connection,
string $database,
int $mysql_version = 80038
) {
$this->mysql_on_sqlite_driver = new WP_PDO_MySQL_On_SQLite(
sprintf( 'mysql-on-sqlite:dbname=%s', $database ),
null,
null,
array(
'mysql_version' => $mysql_version,
'pdo' => $connection->get_pdo(),
)
);
$this->main_db_name = $database;
$this->client_info = $this->mysql_on_sqlite_driver->client_info;
$connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true );
}
/**
* Get the SQLite connection instance.
*
* @return WP_SQLite_Connection
*/
public function get_connection(): WP_SQLite_Connection {
return $this->mysql_on_sqlite_driver->get_connection();
}
/**
* Get the version of the SQLite engine.
*
* @return string SQLite engine version as a string.
*/
public function get_sqlite_version(): string {
return $this->mysql_on_sqlite_driver->get_sqlite_version();
}
/**
* Get the SQLite driver version saved in the database.
*
* The saved driver version corresponds to the latest version of the SQLite
* driver that was used to initialize and configure the SQLite database.
*
* @return string SQLite driver version as a string.
* @throws PDOException When the query execution fails.
*/
public function get_saved_driver_version(): string {
return $this->mysql_on_sqlite_driver->get_saved_driver_version();
}
/**
* Check if a specific SQL mode is active.
*
* @param string $mode The SQL mode to check.
* @return bool True if the SQL mode is active, false otherwise.
*/
public function is_sql_mode_active( string $mode ): bool {
return $this->mysql_on_sqlite_driver->is_sql_mode_active( $mode );
}
/**
* Get the last executed MySQL query.
*
* @return string|null
*/
public function get_last_mysql_query(): ?string {
return $this->mysql_on_sqlite_driver->get_last_mysql_query();
}
/**
* Get SQLite queries executed for the last MySQL query.
*
* @return array{ sql: string, params: array }[]
*/
public function get_last_sqlite_queries(): array {
return $this->mysql_on_sqlite_driver->get_last_sqlite_queries();
}
/**
* Get the auto-increment value generated for the last query.
*
* @return int|string
*/
public function get_insert_id() {
return $this->mysql_on_sqlite_driver->get_insert_id();
}
/**
* @param string $query Full SQL statement string.
* @param int $fetch_mode PDO fetch mode. Default is PDO::FETCH_OBJ.
* @param array ...$fetch_mode_args Additional fetch mode arguments.
*
* @return mixed Return value, depending on the query type.
*
* @throws WP_SQLite_Driver_Exception When the query execution fails.
*/
public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) {
$stmt = $this->mysql_on_sqlite_driver->query( $query, $fetch_mode, ...$fetch_mode_args );
if ( $stmt->columnCount() > 0 ) {
$this->last_result = $stmt->fetchAll( $fetch_mode );
} else {
$this->last_result = $stmt->rowCount();
}
return $this->last_result;
}
/**
* Tokenize a MySQL query and initialize a parser.
*
* @param string $query The MySQL query to parse.
* @return WP_MySQL_Parser A parser initialized for the MySQL query.
*/
public function create_parser( string $query ): WP_MySQL_Parser {
return $this->mysql_on_sqlite_driver->create_parser( $query );
}
/**
* Get results of the last query.
*
* @return mixed
*/
public function get_query_results() {
return $this->last_result;
}
/**
* Get return value of the last query() function call.
*
* @return mixed
*/
public function get_last_return_value() {
return $this->last_result;
}
/**
* Get the number of columns returned by the last emulated query.
*
* @return int
*/
public function get_last_column_count(): int {
return $this->mysql_on_sqlite_driver->get_last_column_count();
}
/**
* Get column metadata for results of the last emulated query.
*
* @return array
*/
public function get_last_column_meta(): array {
return $this->mysql_on_sqlite_driver->get_last_column_meta();
}
/**
* Execute a query in SQLite.
*
* @param string $sql The query to execute.
* @param array $params The query parameters.
* @throws PDOException When the query execution fails.
* @return PDOStatement The PDO statement object.
*/
public function execute_sqlite_query( string $sql, array $params = array() ): PDOStatement {
return $this->mysql_on_sqlite_driver->execute_sqlite_query( $sql, $params );
}
/**
* Begin a new transaction or nested transaction.
*/
public function beginTransaction(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
$this->mysql_on_sqlite_driver->begin_transaction();
}
/**
* A temporary alias for back compatibility.
*
* @see self::beginTransaction()
*/
public function begin_transaction(): void {
$this->beginTransaction();
}
/**
* Commit the current transaction or nested transaction.
*/
public function commit(): void {
$this->mysql_on_sqlite_driver->commit();
}
/**
* Rollback the current transaction or nested transaction.
*/
public function rollback(): void {
$this->mysql_on_sqlite_driver->rollback();
}
/**
* Proxy also the private property "$main_db_name", as it is used in tests.
*/
public function __set( string $name, $value ): void {
if ( 'main_db_name' === $name ) {
$closure = function ( string $value ) {
$this->main_db_name = $value;
};
$closure->call( $this->mysql_on_sqlite_driver, $value );
}
}
/**
* Proxy also this private method, as it is used in tests.
*/
private function quote_mysql_utf8_string_literal( string $utf8_literal ): string {
$closure = function ( string $utf8_literal ) {
return $this->quote_mysql_utf8_string_literal( $utf8_literal );
};
return $closure->call( $this->mysql_on_sqlite_driver, $utf8_literal );
}
}
@@ -0,0 +1,152 @@
<?php
/**
* SQLite information schema exception.
*
* This class is used to represent errors that may occur when building
* the MySQL information schema for emulation in SQLite.
*/
class WP_SQLite_Information_Schema_Exception extends Exception {
// Information schema exception types.
const TYPE_DUPLICATE_TABLE_NAME = 'duplicate-table-name';
const TYPE_DUPLICATE_COLUMN_NAME = 'duplicate-column-name';
const TYPE_DUPLICATE_KEY_NAME = 'duplicate-key-name';
const TYPE_KEY_COLUMN_NOT_FOUND = 'key-column-not-found';
const TYPE_CONSTRAINT_DOES_NOT_EXIST = 'constraint-does-not-exist';
const TYPE_MULTIPLE_CONSTRAINTS_WITH_NAME = 'multiple-constraints-with-name';
/**
* The exception type.
*
* @var string
*/
private $type;
/**
* The data to be passed with the exception.
*
* @var array
*/
private $data;
/**
* Constructor.
*
* @param string $type The exception type.
* @param string $message The exception message.
* @param array $data The data to be passed with the exception.
* @param Throwable|null $previous The previous throwable used for the exception chaining.
*/
public function __construct(
string $type,
string $message,
array $data = array(),
?Throwable $previous = null
) {
parent::__construct( $message, 0, $previous );
$this->type = $type;
$this->data = $data;
}
/**
* Get the type of the exception.
*
* @return string The type of the exception.
*/
public function get_type(): string {
return $this->type;
}
/**
* Get the data associated with the exception.
*
* @return array The data associated with the exception.
*/
public function get_data(): array {
return $this->data;
}
/**
* Create a duplicate table name exception.
*
* @param string $table_name The name of the affected table.
* @return self The exception instance.
*/
public static function duplicate_table_name( string $table_name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_DUPLICATE_TABLE_NAME,
sprintf( "Table '%s' already exists.", $table_name ),
array( 'table_name' => $table_name )
);
}
/**
* Create a duplicate column name exception.
*
* @param string $column_name The name of the affected column.
* @return self The exception instance.
*/
public static function duplicate_column_name( string $column_name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_DUPLICATE_COLUMN_NAME,
sprintf( "Column '%s' already exists.", $column_name ),
array( 'column_name' => $column_name )
);
}
/**
* Create a duplicate key name exception.
*
* @param string $key_name The name of the affected key.
* @return self The exception instance.
*/
public static function duplicate_key_name( string $key_name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_DUPLICATE_KEY_NAME,
sprintf( "Key '%s' already exists.", $key_name ),
array( 'key_name' => $key_name )
);
}
/**
* Create a key column not found exception.
*
* @param string $column_name The name of the affected column.
* @return self The exception instance.
*/
public static function key_column_not_found( string $column_name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_KEY_COLUMN_NOT_FOUND,
sprintf( "Key column '%s' doesn't exist in table.", $column_name ),
array( 'column_name' => $column_name )
);
}
/**
* Create a constraint does not exist exception.
*
* @param string $name The name of the affected constraint.
* @return self The exception instance.
*/
public static function constraint_does_not_exist( string $name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_CONSTRAINT_DOES_NOT_EXIST,
sprintf( "Constraint '%s' does not exist.", $name ),
array( 'name' => $name )
);
}
/**
* Create a multiple constraints with name exception.
*
* @param string $name The name of the affected constraint.
* @return self The exception instance.
*/
public static function multiple_constraints_with_name( string $name ): WP_SQLite_Information_Schema_Exception {
return new self(
self::TYPE_MULTIPLE_CONSTRAINTS_WITH_NAME,
sprintf( "Table has multiple constraints with the name '%s'. Please use constraint specific 'DROP' clause.", $name ),
array( 'name' => $name )
);
}
}
@@ -0,0 +1,802 @@
<?php
/*
* The SQLite driver uses PDO. Enable PDO function calls:
* phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
*/
/**
* SQLite information schema recconstructor for MySQL.
*
* This class checks and reconstructs the MySQL INFORMATION_SCHEMA data in SQLite
* when it becomes out of sync with the actual SQLite database schema.
*
* Currently, it reconstructs schema infromation for missing tables, and removes
* stale data for tables that no longer exist. When used with WordPress, it uses
* the "wp_get_db_schema()" function to reconstruct WordPress table information.
*/
class WP_SQLite_Information_Schema_Reconstructor {
/**
* The SQLite driver instance.
*
* @var WP_PDO_MySQL_On_SQLite
*/
private $driver;
/**
* An instance of the SQLite connection.
*
* @var WP_SQLite_Connection
*/
private $connection;
/**
* A service for managing MySQL INFORMATION_SCHEMA tables in SQLite.
*
* @var WP_SQLite_Information_Schema_Builder
*/
private $schema_builder;
/**
* Constructor.
*
* @param WP_PDO_MySQL_On_SQLite $driver The SQLite driver instance.
* @param WP_SQLite_Information_Schema_Builder $schema_builder The information schema builder instance.
*/
public function __construct(
$driver,
WP_SQLite_Information_Schema_Builder $schema_builder
) {
$this->driver = $driver;
$this->connection = $driver->get_connection();
$this->schema_builder = $schema_builder;
}
/**
* Ensure that the MySQL INFORMATION_SCHEMA data in SQLite is correct.
*
* This method checks if the MySQL INFORMATION_SCHEMA data in SQLite is correct,
* and if it is not, it will reconstruct missing data and remove stale values.
*/
public function ensure_correct_information_schema(): void {
$sqlite_tables = $this->get_sqlite_table_names();
$information_schema_tables = $this->get_information_schema_table_names();
// In WordPress, use "wp_get_db_schema()" to reconstruct WordPress tables.
$wp_tables = $this->get_wp_create_table_statements();
// Reconstruct information schema records for tables that don't have them.
foreach ( $sqlite_tables as $table ) {
if ( ! in_array( $table, $information_schema_tables, true ) ) {
if ( isset( $wp_tables[ $table ] ) ) {
// WordPress core table (as returned by "wp_get_db_schema()").
$ast = $wp_tables[ $table ];
} else {
// Other table (a WordPress plugin or unrelated to WordPress).
$sql = $this->generate_create_table_statement( $table );
$ast = $this->driver->create_parser( $sql )->parse();
if ( null === $ast ) {
throw new WP_SQLite_Driver_Exception( $this->driver, 'Failed to parse the MySQL query.' );
}
}
/*
* First, let's make sure we clean up all related data. This fixes
* partial data corruption, such as when a table record is missing,
* but some related column, index, or constraint records are stored.
*/
$this->record_drop_table( $table );
$this->schema_builder->record_create_table( $ast );
}
}
// Remove information schema records for tables that don't exist.
foreach ( $information_schema_tables as $table ) {
if ( ! in_array( $table, $sqlite_tables, true ) ) {
$this->record_drop_table( $table );
}
}
}
/**
* Record a DROP TABLE statement in the information schema.
*
* This removes a table record from the information schema, as well as all
* column, index, and constraint records that are related to the table.
*
* @param string $table_name The name of the table to drop.
*/
private function record_drop_table( string $table_name ): void {
$sql = sprintf( 'DROP TABLE %s', $this->connection->quote_identifier( $table_name ) ); // TODO: mysql quote
$ast = $this->driver->create_parser( $sql )->parse();
if ( null === $ast ) {
throw new WP_SQLite_Driver_Exception( $this->driver, 'Failed to parse the MySQL query.' );
}
$this->schema_builder->record_drop_table(
$ast->get_first_descendant_node( 'dropStatement' )
);
}
/**
* Get the names of all existing tables in the SQLite database.
*
* @return string[] The names of tables in the SQLite database.
*/
private function get_sqlite_table_names(): array {
return $this->driver->execute_sqlite_query(
"
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name != ?
AND name NOT LIKE ? ESCAPE '\'
AND name NOT LIKE ? ESCAPE '\'
ORDER BY name
",
array(
'_mysql_data_types_cache',
'sqlite\_%',
str_replace( '_', '\_', WP_PDO_MySQL_On_SQLite::RESERVED_PREFIX ) . '%',
)
)->fetchAll( PDO::FETCH_COLUMN );
}
/**
* Get the names of all tables recorded in the information schema.
*
* @return string[] The names of tables in the information schema.
*/
private function get_information_schema_table_names(): array {
$tables_table = $this->schema_builder->get_table_name( false, 'tables' );
return $this->driver->execute_sqlite_query(
sprintf(
'SELECT table_name FROM %s ORDER BY table_name',
$this->connection->quote_identifier( $tables_table )
)
)->fetchAll( PDO::FETCH_COLUMN );
}
/**
* Get a map of parsed CREATE TABLE statements for WordPress tables.
*
* When reconstructing the information schema data for WordPress tables, we
* can use the "wp_get_db_schema()" function to get accurate CREATE TABLE
* statements. This method parses the result of "wp_get_db_schema()" into
* an array of parsed CREATE TABLE statements indexed by the table names.
*
* @return array<string, WP_Parser_Node> The WordPress CREATE TABLE statements.
*/
private function get_wp_create_table_statements(): array {
// Bail out when not in a WordPress environment.
if ( ! defined( 'ABSPATH' ) ) {
return array();
}
/*
* In WP CLI, $wpdb may not be set. In that case, we can't load the schema.
* We need to bail out and use the standard non-WordPress-specific behavior.
*/
global $wpdb;
if ( ! isset( $wpdb ) ) {
// Outside of WP CLI, let's trigger a warning.
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
trigger_error( 'The $wpdb global is not initialized.', E_USER_WARNING );
}
return array();
}
// Ensure the "wp_get_db_schema()" function is defined.
if ( file_exists( ABSPATH . 'wp-admin/includes/schema.php' ) ) {
require_once ABSPATH . 'wp-admin/includes/schema.php';
}
if ( ! function_exists( 'wp_get_db_schema' ) ) {
throw new Exception( 'The "wp_get_db_schema()" function was not defined.' );
}
/*
* At this point, WPDB may not yet be initialized, as we're configuring
* the database connection. Let's only populate the table names using
* the "$table_prefix" global so we can get correct table names.
*/
global $table_prefix;
$wpdb->set_prefix( $table_prefix );
// Get schema for global tables.
$schema = wp_get_db_schema( 'global' );
// For multisite installs, add schema definitions for all sites.
if ( is_multisite() ) {
/*
* We need to use a database query over the "get_sites()" function,
* as WPDB may not yet initialized. Moreover, we need to get the IDs
* of all existing blogs, independent of any filters and actions that
* could possibly alter the results of a "get_sites()" call.
*/
$blog_ids = $this->driver->execute_sqlite_query(
sprintf(
'SELECT blog_id FROM %s',
$this->connection->quote_identifier( $wpdb->blogs )
)
)->fetchAll( PDO::FETCH_COLUMN );
foreach ( $blog_ids as $blog_id ) {
$schema .= wp_get_db_schema( 'blog', (int) $blog_id );
}
} else {
// For single site installs, add schema for the main site.
$schema .= wp_get_db_schema( 'blog' );
}
// Parse the schema.
$parser = $this->driver->create_parser( $schema );
$wp_tables = array();
while ( $parser->next_query() ) {
$ast = $parser->get_query_ast();
if ( null === $ast ) {
throw new WP_SQLite_Driver_Exception( $this->driver, 'Failed to parse the MySQL query.' );
}
$create_node = $ast->get_first_descendant_node( 'createStatement' );
if ( $create_node && $create_node->has_child_node( 'createTable' ) ) {
$name_node = $create_node->get_first_descendant_node( 'tableName' );
$name = $this->unquote_mysql_identifier(
substr( $schema, $name_node->get_start(), $name_node->get_length() )
);
$wp_tables[ $name ] = $create_node;
}
}
return $wp_tables;
}
/**
* Generate a MySQL CREATE TABLE statement from an SQLite table definition.
*
* @param string $table_name The name of the table.
* @return string The CREATE TABLE statement.
*/
private function generate_create_table_statement( string $table_name ): string {
// Columns.
$columns = $this->driver->execute_sqlite_query(
sprintf(
'PRAGMA table_xinfo(%s)',
$this->connection->quote_identifier( $table_name )
)
)->fetchAll( PDO::FETCH_ASSOC );
$definitions = array();
$column_types = array();
foreach ( $columns as $column ) {
$mysql_type = $this->get_cached_mysql_data_type( $table_name, $column['name'] );
if ( null === $mysql_type ) {
$mysql_type = $this->get_mysql_column_type( $column['type'] );
}
$definitions[] = $this->generate_column_definition( $table_name, $column );
$column_types[ $column['name'] ] = $mysql_type;
}
// Primary key.
$pk_columns = array();
foreach ( $columns as $column ) {
// A position of the column in the primary key, starting from index 1.
// A value of 0 means that the column is not part of the primary key.
$pk_position = (int) $column['pk'];
if ( 0 !== $pk_position ) {
$pk_columns[ $pk_position ] = $column['name'];
}
}
// Sort the columns by their position in the primary key.
ksort( $pk_columns );
if ( count( $pk_columns ) > 0 ) {
$quoted_pk_columns = array();
foreach ( $pk_columns as $pk_column ) {
$quoted_pk_columns[] = $this->connection->quote_identifier( $pk_column );
}
$definitions[] = sprintf( 'PRIMARY KEY (%s)', implode( ', ', $quoted_pk_columns ) );
}
// Indexes and keys.
$keys = $this->driver->execute_sqlite_query(
sprintf(
'PRAGMA index_list(%s)',
$this->connection->quote_identifier( $table_name )
)
)->fetchAll( PDO::FETCH_ASSOC );
foreach ( $keys as $key ) {
// Skip the internal index that SQLite may create for a primary key.
// In MySQL, no explicit index needs to be defined for a primary key.
if ( 'pk' === $key['origin'] ) {
continue;
}
$definitions[] = $this->generate_key_definition( $table_name, $key, $column_types );
}
return sprintf(
"CREATE TABLE %s (\n %s\n)",
$this->connection->quote_identifier( $table_name ),
implode( ",\n ", $definitions )
);
}
/**
* Generate a MySQL column definition from an SQLite column information.
*
* This method generates a MySQL column definition from SQLite column data.
*
* @param string $table_name The name of the table.
* @param array $column_info The SQLite column information.
* @return string The MySQL column definition.
*/
private function generate_column_definition( string $table_name, array $column_info ): string {
$definition = array();
$definition[] = $this->connection->quote_identifier( $column_info['name'] );
// Data type.
$mysql_type = $this->get_cached_mysql_data_type( $table_name, $column_info['name'] );
if ( null === $mysql_type ) {
$mysql_type = $this->get_mysql_column_type( $column_info['type'] );
}
/**
* Correct some column types based on their default values:
* 1. In MySQL, non-datetime columns can't have a timestamp default.
* Let's use DATETIME when default is set to CURRENT_TIMESTAMP.
* 2. In MySQL, TEXT and BLOB columns can't have a default value.
* Let's use VARCHAR(65535) and VARBINARY(65535) when default is set.
*/
$default = $this->generate_column_default( $mysql_type, $column_info['dflt_value'] );
if ( 'CURRENT_TIMESTAMP' === $default ) {
$mysql_type = 'datetime';
} elseif ( 'text' === $mysql_type && null !== $default ) {
$mysql_type = 'varchar(65535)';
} elseif ( 'blob' === $mysql_type && null !== $default ) {
$mysql_type = 'varbinary(65535)';
}
$definition[] = $mysql_type;
// NULL/NOT NULL.
if ( '1' === $column_info['notnull'] ) {
$definition[] = 'NOT NULL';
}
// Auto increment.
$is_auto_increment = false;
if ( '0' !== $column_info['pk'] ) {
$is_auto_increment = $this->driver->execute_sqlite_query(
'SELECT 1 FROM sqlite_master WHERE tbl_name = ? AND sql LIKE ?',
array( $table_name, '%AUTOINCREMENT%' )
)->fetchColumn();
if ( $is_auto_increment ) {
$definition[] = 'AUTO_INCREMENT';
}
}
// Default value.
if ( null !== $default && ! $is_auto_increment ) {
$definition[] = 'DEFAULT ' . $default;
}
return implode( ' ', $definition );
}
/**
* Generate a MySQL key definition from an SQLite key information.
*
* This method generates a MySQL key definition from SQLite key data.
*
* @param string $table_name The name of the table.
* @param array $key_info The SQLite key information.
* @param array $column_types The MySQL data types of the columns.
* @return string The MySQL key definition.
*/
private function generate_key_definition( string $table_name, array $key_info, array $column_types ): string {
$definition = array();
// Key type.
$cached_type = $this->get_cached_mysql_data_type( $table_name, $key_info['name'] );
if ( 'FULLTEXT' === $cached_type ) {
$definition[] = 'FULLTEXT KEY';
} elseif ( 'SPATIAL' === $cached_type ) {
$definition[] = 'SPATIAL KEY';
} elseif ( 'UNIQUE' === $cached_type || '1' === $key_info['unique'] ) {
$definition[] = 'UNIQUE KEY';
} else {
$definition[] = 'KEY';
}
// Key name.
$name = $key_info['name'];
/*
* The SQLite driver prefixes index names with "{$table_name}__" to avoid
* naming conflicts among tables in SQLite. We need to remove the prefix.
*/
if ( str_starts_with( $name, "{$table_name}__" ) ) {
$name = substr( $name, strlen( "{$table_name}__" ) );
}
/**
* SQLite creates automatic internal indexes for primary and unique keys,
* naming them in format "sqlite_autoindex_{$table_name}_{$index_id}".
* For these internal indexes, we need to skip their name, so that in
* the generated MySQL definition, they follow implicit MySQL naming.
*/
if ( ! str_starts_with( $name, 'sqlite_autoindex_' ) ) {
$definition[] = $this->connection->quote_identifier( $name );
}
// Key columns.
$key_columns = $this->driver->execute_sqlite_query(
sprintf(
'PRAGMA index_info(%s)',
$this->connection->quote_identifier( $key_info['name'] )
)
)->fetchAll( PDO::FETCH_ASSOC );
$cols = array();
foreach ( $key_columns as $column ) {
/*
* Extract type and length from column data type definition.
*
* This is required when the column data type is inferred from the
* '_mysql_data_types_cache' table, which stores the data type in
* the format "type(length)", such as "varchar(255)".
*/
$max_prefix_length = 100;
$type = strtolower( $column_types[ $column['name'] ] );
$parts = explode( '(', $type );
$column_type = $parts[0];
$column_length = isset( $parts[1] ) ? (int) $parts[1] : null;
/*
* Add an index column prefix length, if needed.
*
* This is required for "text" and "blob" types for columns inferred
* directly from the SQLite schema, and for the following types for
* columns inferred from the '_mysql_data_types_cache' table:
* char, varchar
* text, tinytext, mediumtext, longtext
* blob, tinyblob, mediumblob, longblob
* varbinary
*/
if (
str_ends_with( $column_type, 'char' )
|| str_ends_with( $column_type, 'text' )
|| str_ends_with( $column_type, 'blob' )
|| str_starts_with( $column_type, 'var' )
) {
$cols[] = sprintf(
'%s(%d)',
$this->connection->quote_identifier( $column['name'] ),
min( $column_length ?? $max_prefix_length, $max_prefix_length )
);
} else {
$cols[] = $this->connection->quote_identifier( $column['name'] );
}
}
$definition[] = '(' . implode( ', ', $cols ) . ')';
return implode( ' ', $definition );
}
/**
* Generate a MySQL default value from an SQLite default value.
*
* @param string $mysql_type The MySQL data type of the column.
* @param string|null $default_value The default value of the SQLite column.
* @return string|null The default value, or null if the column has no default value.
*/
private function generate_column_default( string $mysql_type, ?string $default_value ): ?string {
if ( null === $default_value || '' === $default_value ) {
return null;
}
$mysql_type = strtolower( $mysql_type );
if ( str_starts_with( $mysql_type, 'bit' ) ) {
// BIT columns are stored as INTEGER in SQLite.
return "b'" . decbin( (int) $default_value ) . "'";
}
/*
* In MySQL, geometry columns can't have a default value.
*
* Geometry columns are saved as TEXT in SQLite, and in an older version
* of the SQLite driver, TEXT columns were assigned a default value of ''.
*/
if ( 'geomcollection' === $mysql_type || 'geometrycollection' === $mysql_type ) {
return null;
}
/*
* In MySQL, date/time columns can't have a default value of ''.
*
* Date/time columns are saved as TEXT in SQLite, and in an older version
* of the SQLite driver, TEXT columns were assigned a default value of ''.
*/
if (
"''" === $default_value
&& in_array( $mysql_type, array( 'datetime', 'date', 'time', 'timestamp', 'year' ), true )
) {
return null;
}
/**
* Convert SQLite default values to MySQL default values.
*
* See:
* - https://www.sqlite.org/syntax/column-constraint.html
* - https://www.sqlite.org/syntax/literal-value.html
* - https://www.sqlite.org/lang_expr.html#literal_values_constants_
*/
// Quoted string literal. E.g.: 'abc', "abc", `abc`
$first_byte = $default_value[0] ?? null;
if ( '"' === $first_byte || "'" === $first_byte || '`' === $first_byte ) {
$value = substr( $default_value, 1, -1 );
$value = str_replace( $first_byte . $first_byte, $first_byte, $value );
return $this->quote_mysql_utf8_string_literal( $value );
}
// Normalize the default value for easier comparison.
$uppercase_default_value = strtoupper( $default_value );
// NULL, TRUE, FALSE.
if ( 'NULL' === $uppercase_default_value ) {
// DEFAULT NULL is the same as no default value.
return null;
} elseif ( 'TRUE' === $uppercase_default_value ) {
return '1';
} elseif ( 'FALSE' === $uppercase_default_value ) {
return '0';
}
// Date/time values.
if ( 'CURRENT_TIMESTAMP' === $uppercase_default_value ) {
return 'CURRENT_TIMESTAMP';
} elseif ( 'CURRENT_DATE' === $uppercase_default_value ) {
return null; // Not supported in MySQL.
} elseif ( 'CURRENT_TIME' === $uppercase_default_value ) {
return null; // Not supported in MySQL.
}
// SQLite supports underscores in all numeric literals.
$no_underscore_default_value = str_replace( '_', '', $default_value );
// Numeric literals. E.g.: 123, 1.23, -1.23, 1e3, 1.2e-3
if ( is_numeric( $no_underscore_default_value ) ) {
return $no_underscore_default_value;
}
// HEX literals (numeric). E.g.: 0x1a2f, 0X1A2F
$value = filter_var( $no_underscore_default_value, FILTER_VALIDATE_INT, FILTER_FLAG_ALLOW_HEX );
if ( false !== $value ) {
return $value;
}
// BLOB literals (string). E.g.: x'1a2f', X'1A2F'
// Checking the prefix is enough as SQLite doesn't allow malformed values.
if ( str_starts_with( $uppercase_default_value, "X'" ) ) {
// Convert the hex string to ASCII bytes.
return "'" . pack( 'H*', substr( $default_value, 2, -1 ) ) . "'";
}
// Unquoted string literal. E.g.: abc
return $this->quote_mysql_utf8_string_literal( $default_value );
}
/**
* Get a MySQL column or index data type from legacy data types cache table.
*
* This method retrieves MySQL column or index data types from a special table
* that was used by an old version of the SQLite driver and that is otherwise
* no longer needed. This is more precise than direct inference from SQLite.
*
* For columns, it returns full column type, including prefix length, e.g.:
* int(11), bigint(20) unsigned, varchar(255), longtext
*
* For indexes, it returns one of:
* KEY, PRIMARY, UNIQUE, FULLTEXT, SPATIAL
*
* @param string $table_name The table name.
* @param string $column_or_index_name The column or index name.
* @return string|null The MySQL definition, or null when not found.
*/
private function get_cached_mysql_data_type( string $table_name, string $column_or_index_name ): ?string {
try {
$mysql_type = $this->driver->execute_sqlite_query(
'SELECT mysql_type FROM _mysql_data_types_cache
WHERE `table` = ? COLLATE NOCASE
AND (
-- The old SQLite driver stored the MySQL data types in multiple
-- formats - lowercase, uppercase, and, sometimes, with backticks.
column_or_index = ? COLLATE NOCASE
OR column_or_index = ? COLLATE NOCASE
)',
array( $table_name, $column_or_index_name, "`$column_or_index_name`" )
)->fetchColumn();
} catch ( PDOException $e ) {
if ( str_contains( $e->getMessage(), 'no such table' ) ) {
return null;
}
throw $e;
}
if ( false === $mysql_type ) {
return null;
}
/**
* Check whether the stored type value is a valid MySQL column type.
*
* Some older versions of the legacy SQLite driver might have stored
* invalid MySQL column types in some scenarios:
*
* 1. Before https://github.com/WordPress/sqlite-database-integration/pull/126,
* the legacy SQLite driver incorrectly stored MySQL column types
* for columns with multiple type arguments.
*
* E.g., a column definition like "col_name decimal(26, 8)" would
* be stored with invalid type "decimal(26,".
*
* 2. Before https://github.com/WordPress/sqlite-database-integration/commit/b5a9fbaed4d0d843f792aaa959e3d00f193ff1ee
* (see also https://github.com/Automattic/sqlite-database-integration/pull/2),
* the legacy SQLite driver incorrectly recognized indexes on columns
* with type keywords as additional table column definitions.
*
* E.g., an index definition like "KEY timestamp (timestamp)" would
* be stored as column "KEY" with invalid type "timestamp(timestamp)".
*
* To address these issues, we need to check whether the stored type looks
* like a valid MySQL column type definition.
*/
$open_par_index = strpos( $mysql_type, '(' );
$close_par_index = strpos( $mysql_type, ')' );
if ( false !== $open_par_index ) {
$end = false !== $close_par_index ? $close_par_index : strlen( $mysql_type );
$parts = explode( '(', substr( $mysql_type, 0, $end ) );
$type = strtolower( trim( $parts[0] ) );
$args = array();
foreach ( explode( ',', $parts[1] ) as $arg ) {
$args[] = strtolower( trim( $arg ) );
}
// WooCommerce uses decimal(26,8), decimal(19,4), and decimal(3,2)
// column types, so we can can fix the invalid column definitions.
$looks_like_wc_table = str_contains( $table_name, 'wc_' ) || str_contains( $table_name, 'woocommerce_' );
$is_invalid_decimal = 'decimal' === $type && count( $args ) === 2 && '' === $args[1];
if ( $looks_like_wc_table && $is_invalid_decimal ) {
if ( '26' === $args[0] ) {
// Fix "decimal(26,".
return 'decimal(26,8)';
} elseif ( '19' === $args[0] ) {
// Fix "decimal(19,".
return 'decimal(19,4)';
} elseif ( '3' === $args[0] ) {
// Fix "decimal(3,".
return 'decimal(3,2)';
}
}
// Only numeric arguments are allowed for MySQL column types.
// This handles the incorrectly stored index definition case.
foreach ( $args as $arg ) {
if ( ! is_numeric( $arg ) ) {
return null;
}
}
// If there is no closing parenthesis, the type is invalid.
if ( false === $close_par_index ) {
return null;
}
}
// Normalize index type for backward compatibility. Some older versions
// of the SQLite driver stored index types with a " KEY" suffix, e.g.,
// "PRIMARY KEY" or "UNIQUE KEY". More recent versions omit the suffix.
if ( str_ends_with( $mysql_type, ' KEY' ) ) {
$mysql_type = substr( $mysql_type, 0, strlen( $mysql_type ) - strlen( ' KEY' ) );
}
return $mysql_type;
}
/**
* Get a MySQL column type from an SQLite column type.
*
* This method converts an SQLite column type to a MySQL column type as per
* the SQLite column type affinity rules:
* https://sqlite.org/datatype3.html#determination_of_column_affinity
*
* @param string $column_type The SQLite column type.
* @return string The MySQL column type.
*/
private function get_mysql_column_type( string $column_type ): string {
$type = strtoupper( $column_type );
/*
* Following the rules of column affinity:
* https://sqlite.org/datatype3.html#determination_of_column_affinity
*/
// 1. If the declared type contains the string "INT" then it is assigned
// INTEGER affinity.
if ( str_contains( $type, 'INT' ) ) {
return 'int';
}
// 2. If the declared type of the column contains any of the strings
// "CHAR", "CLOB", or "TEXT" then that column has TEXT affinity.
if ( str_contains( $type, 'TEXT' ) || str_contains( $type, 'CHAR' ) || str_contains( $type, 'CLOB' ) ) {
return 'text';
}
// 3. If the declared type for a column contains the string "BLOB" or
// if no type is specified then the column has affinity BLOB.
if ( str_contains( $type, 'BLOB' ) || '' === $type ) {
return 'blob';
}
// 4. If the declared type for a column contains any of the strings
// "REAL", "FLOA", or "DOUB" then the column has REAL affinity.
if ( str_contains( $type, 'REAL' ) || str_contains( $type, 'FLOA' ) ) {
return 'float';
}
if ( str_contains( $type, 'DOUB' ) ) {
return 'double';
}
/**
* 5. Otherwise, the affinity is NUMERIC.
*
* While SQLite defaults to a NUMERIC column affinity, it's better to use
* TEXT in this case, because numeric SQLite columns in non-strict tables
* can contain any text data as well, when it is not a well-formed number.
*
* See: https://sqlite.org/datatype3.html#type_affinity
*/
return 'text';
}
/**
* Format a MySQL UTF-8 string literal for output in a CREATE TABLE statement.
*
* See WP_PDO_MySQL_On_SQLite::quote_mysql_utf8_string_literal().
*
* TODO: This is a copy of WP_PDO_MySQL_On_SQLite::quote_mysql_utf8_string_literal().
* We may consider extracing it to reusable MySQL helpers.
*
* @param string $utf8_literal The UTF-8 string literal to escape.
* @return string The escaped string literal.
*/
private function quote_mysql_utf8_string_literal( string $utf8_literal ): string {
$backslash = chr( 92 );
$replacements = array(
"'" => "''", // A single quote character (').
$backslash => $backslash . $backslash, // A backslash character (\).
chr( 0 ) => $backslash . '0', // An ASCII NULL character (\0).
chr( 10 ) => $backslash . 'n', // A newline (linefeed) character (\n).
chr( 13 ) => $backslash . 'r', // A carriage return character (\r).
);
return "'" . strtr( $utf8_literal, $replacements ) . "'";
}
/**
* Unquote a quoted MySQL identifier.
*
* Remove bounding quotes and replace escaped quotes with their values.
*
* @param string $quoted_identifier The quoted identifier value.
* @return string The unquoted identifier value.
*/
private function unquote_mysql_identifier( string $quoted_identifier ): string {
$first_byte = $quoted_identifier[0] ?? null;
if ( '"' === $first_byte || '`' === $first_byte ) {
$unquoted = substr( $quoted_identifier, 1, -1 );
return str_replace( $first_byte . $first_byte, $first_byte, $unquoted );
}
return $quoted_identifier;
}
}
@@ -0,0 +1,987 @@
<?php
/**
* Custom functions for the SQLite implementation.
*
* @package wp-sqlite-integration
* @since 1.0.0
*/
/**
* This class defines user defined functions(UDFs) for PDO library.
*
* These functions replace those used in the SQL statement with the PHP functions.
*
* Usage:
*
* <code>
* new WP_SQLite_PDO_User_Defined_Functions(ref_to_pdo_obj);
* </code>
*
* This automatically enables ref_to_pdo_obj to replace the function in the SQL statement
* to the ones defined here.
*/
class WP_SQLite_PDO_User_Defined_Functions {
/**
* Registers the user defined functions for SQLite to a PDO instance.
* The functions are registered using PDO::sqliteCreateFunction().
*
* @param PDO|PDO\SQLite $pdo The PDO object.
*/
public static function register_for( $pdo ): self {
$instance = new self();
foreach ( $instance->functions as $f => $t ) {
if ( $pdo instanceof PDO\SQLite ) {
$pdo->createFunction( $f, array( $instance, $t ) );
} else {
$pdo->sqliteCreateFunction( $f, array( $instance, $t ) );
}
}
return $instance;
}
/**
* Array to define MySQL function => function defined with PHP.
*
* Replaced functions must be public.
*
* @var array
*/
private $functions = array(
'throw' => 'throw',
'month' => 'month',
'monthnum' => 'month',
'year' => 'year',
'day' => 'day',
'hour' => 'hour',
'minute' => 'minute',
'second' => 'second',
'week' => 'week',
'weekday' => 'weekday',
'dayofweek' => 'dayofweek',
'dayofmonth' => 'dayofmonth',
'unix_timestamp' => 'unix_timestamp',
'now' => 'now',
'md5' => 'md5',
'curdate' => 'curdate',
'rand' => 'rand',
'from_unixtime' => 'from_unixtime',
'localtime' => 'now',
'localtimestamp' => 'now',
'isnull' => 'isnull',
'if' => '_if',
'regexp' => 'regexp',
'field' => 'field',
'log' => 'log',
'least' => 'least',
'greatest' => 'greatest',
'get_lock' => 'get_lock',
'release_lock' => 'release_lock',
'ucase' => 'ucase',
'lcase' => 'lcase',
'unhex' => 'unhex',
'from_base64' => 'from_base64',
'to_base64' => 'to_base64',
'inet_ntoa' => 'inet_ntoa',
'inet_aton' => 'inet_aton',
'datediff' => 'datediff',
'locate' => 'locate',
'utc_date' => 'utc_date',
'utc_time' => 'utc_time',
'utc_timestamp' => 'utc_timestamp',
'version' => 'version',
// Internal helper functions.
'_helper_like_to_glob_pattern' => '_helper_like_to_glob_pattern',
);
/**
* First element of the RAND(N) LCG state (the value the output is derived from).
*
* @var int|null
*/
private $rand_seed1 = null;
/**
* Second element of the RAND(N) LCG state (the paired value used in the recurrence).
*
* @var int|null
*/
private $rand_seed2 = null;
/**
* Last seed value passed to RAND(N) in the current statement.
*
* Used to detect whether the rand sequence is advancing with the same seed
* (e.g. "SELECT RAND(3) FROM t"), or reseeding (starting a new sequence).
*
* @var int|null
*/
private $rand_last_seed = null;
/**
* Clear any per-statement state held by the UDFs.
*/
public function flush(): void {
$this->rand_seed1 = null;
$this->rand_seed2 = null;
$this->rand_last_seed = null;
}
/**
* A helper function to throw an error from SQLite expressions.
*
* @param string $message The error message.
*
* @throws Exception The error message.
* @return void
*/
public function throw( $message ): void {
throw new Exception( $message );
}
/**
* Method to return the unix timestamp.
*
* Used without an argument, it returns PHP time() function (total seconds passed
* from '1970-01-01 00:00:00' GMT). Used with the argument, it changes the value
* to the timestamp.
*
* @param string $field Representing the date formatted as '0000-00-00 00:00:00'.
*
* @return number of unsigned integer
*/
public function unix_timestamp( $field = null ) {
return is_null( $field ) ? time() : strtotime( $field );
}
/**
* Method to emulate MySQL FROM_UNIXTIME() function.
*
* @param int $field The unix timestamp.
* @param string $format Indicate the way of formatting(optional).
*
* @return string
*/
public function from_unixtime( $field, $format = null ) {
// Convert to ISO time.
$date = gmdate( 'Y-m-d H:i:s', $field );
return is_null( $format ) ? $date : $this->dateformat( $date, $format );
}
/**
* Method to emulate MySQL NOW() function.
*
* @return string representing current time formatted as '0000-00-00 00:00:00'.
*/
public function now() {
return gmdate( 'Y-m-d H:i:s' );
}
/**
* Method to emulate MySQL CURDATE() function.
*
* @return string representing current time formatted as '0000-00-00'.
*/
public function curdate() {
return gmdate( 'Y-m-d' );
}
/**
* Method to emulate MySQL MD5() function.
*
* @param string $field The string to be hashed.
*
* @return string of the md5 hash value of the argument.
*/
public function md5( $field ) {
return md5( $field );
}
/**
* Method to emulate MySQL's seeded RAND(N) function.
*
* Implements MySQL's deterministic LCG (Linear Congruential Generator),
* producing bit-exact output for a given seed.
*
* Known divergences from MySQL:
*
* 1. In MySQL, RAND(N) behaves differently depending on whether the seed
* is constant expression or varies per invocation:
* - Constant seed (e.g. "SELECT RAND(3) FROM t"):
* LCG is initialized once per statement and advanced for each row.
* - Non-constant seed (e.g. "SELECT RAND(col) FROM t"):
* LCG is initialized for every row with its seed value.
*
* A SQLite UDF cannot tell whether the seed expression is constant, so
* we just compare the seed against its last value. This diverges from
* MySQL in rare cases, and we can consider improving it in the future.
*
* 2. The LCG state is shared across call sites in the same query, so
* "SELECT RAND(1), RAND(1)" yields different results here than in MySQL.
* This is a rare edge case that we can consider improving in the future.
*
* Unseeded RAND() never reaches this function. The AST driver translates it
* directly to a more efficient SQLite-native expression.
*
* @param int|float|string|null $seed Seed value.
*
* @return float A value in [0, 1).
*/
public function rand( $seed ) {
// Requires 64-bit PHP. Seed * 0x10000001 can exceed PHP_INT_MAX on 32-bit.
$max_value = 0x3FFFFFFF;
if ( null === $seed ) {
// MySQL treats NULL seed as 0.
$seed = 0;
} elseif ( ! is_int( $seed ) ) {
/*
* MySQL rounds float values and numeric strings take the same path.
* Reduce the value to a 32-bit range using "fmod" to avoid firing
* the "out-of-range float to int" cast deprecation on PHP 8.1+.
*/
$seed = (int) fmod( round( (float) $seed, 0, PHP_ROUND_HALF_EVEN ), 0x100000000 );
}
// Initialize MySQL's internal 30-bit seeds.
if ( $seed !== $this->rand_last_seed ) {
/*
* MySQL casts to uint32, and the intermediate results wrap at 32-bit
* unsigned boundaries. We emulate this with & 0xFFFFFFFF masks.
*/
$seed_u32 = $seed & 0xFFFFFFFF;
$this->rand_seed1 = ( ( $seed_u32 * 0x10001 + 55555555 ) & 0xFFFFFFFF ) % $max_value;
$this->rand_seed2 = ( ( $seed_u32 * 0x10000001 ) & 0xFFFFFFFF ) % $max_value;
$this->rand_last_seed = $seed;
}
/*
* MySQL's LCG recurrence:
* seed1 = (seed1 * 3 + seed2) % max_value
* seed2 = (seed1 + seed2 + 33) % max_value
*
* Note that seed1 is updated first and the new value is used for seed2.
*/
$this->rand_seed1 = ( $this->rand_seed1 * 3 + $this->rand_seed2 ) % $max_value;
$this->rand_seed2 = ( $this->rand_seed1 + $this->rand_seed2 + 33 ) % $max_value;
return (float) $this->rand_seed1 / (float) $max_value;
}
/**
* Method to emulate MySQL DATEFORMAT() function.
*
* @param string $date Formatted as '0000-00-00' or datetime as '0000-00-00 00:00:00'.
* @param string $format The string format.
*
* @return string formatted according to $format
*/
public function dateformat( $date, $format ) {
$mysql_php_date_formats = array(
'%a' => 'D',
'%b' => 'M',
'%c' => 'n',
'%D' => 'jS',
'%d' => 'd',
'%e' => 'j',
'%H' => 'H',
'%h' => 'h',
'%I' => 'h',
'%i' => 'i',
'%j' => 'z',
'%k' => 'G',
'%l' => 'g',
'%M' => 'F',
'%m' => 'm',
'%p' => 'A',
'%r' => 'h:i:s A',
'%S' => 's',
'%s' => 's',
'%T' => 'H:i:s',
'%U' => 'W',
'%u' => 'W',
'%V' => 'W',
'%v' => 'W',
'%W' => 'l',
'%w' => 'w',
'%X' => 'Y',
'%x' => 'o',
'%Y' => 'Y',
'%y' => 'y',
);
$time = strtotime( $date );
$format = strtr( $format, $mysql_php_date_formats );
return gmdate( $format, $time );
}
/**
* Method to extract the month value from the date.
*
* @param string $field Representing the date formatted as 0000-00-00.
*
* @return string Representing the number of the month between 1 and 12.
*/
public function month( $field ) {
/*
* MySQL returns 0 for MONTH('0000-00-00') and for dates with
* zero month parts like '2020-00-15'. PHP's strtotime() can't
* parse these, so we extract the month directly from the string.
*/
if ( preg_match( '/^\d{4}-(\d{2})/', $field, $matches ) ) {
return intval( $matches[1] );
}
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* n - Numeric representation of a month, without leading zeros.
* 1 through 12
*/
return intval( gmdate( 'n', strtotime( $field ) ) );
}
/**
* Method to extract the year value from the date.
*
* @param string $field Representing the date formatted as 0000-00-00.
*
* @return string Representing the number of the year.
*/
public function year( $field ) {
/*
* MySQL returns 0 for YEAR('0000-00-00'). PHP's strtotime()
* can't parse zero dates, so we extract the year directly.
*/
if ( preg_match( '/^(\d{4})-\d{2}/', $field, $matches ) ) {
return intval( $matches[1] );
}
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* Y - A full numeric representation of a year, 4 digits.
*/
return intval( gmdate( 'Y', strtotime( $field ) ) );
}
/**
* Method to extract the day value from the date.
*
* @param string $field Representing the date formatted as 0000-00-00.
*
* @return string Representing the number of the day of the month from 1 and 31.
*/
public function day( $field ) {
/*
* MySQL returns 0 for DAY('0000-00-00') and for dates with
* zero day parts like '2020-01-00'. PHP's strtotime() can't
* parse these, so we extract the day directly from the string.
*/
if ( preg_match( '/^\d{4}-\d{2}-(\d{2})/', $field, $matches ) ) {
return intval( $matches[1] );
}
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* j - Day of the month without leading zeros.
* 1 to 31.
*/
return intval( gmdate( 'j', strtotime( $field ) ) );
}
/**
* Method to emulate MySQL SECOND() function.
*
* @see https://www.php.net/manual/en/datetime.format.php
*
* @param string $field Representing the time formatted as '00:00:00'.
*
* @return number Unsigned integer
*/
public function second( $field ) {
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* s - Seconds, with leading zeros (00 to 59)
*/
return intval( gmdate( 's', strtotime( $field ) ) );
}
/**
* Method to emulate MySQL MINUTE() function.
*
* @param string $field Representing the time formatted as '00:00:00'.
*
* @return int
*/
public function minute( $field ) {
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* i - Minutes with leading zeros.
* 00 to 59.
*/
return intval( gmdate( 'i', strtotime( $field ) ) );
}
/**
* Method to emulate MySQL HOUR() function.
*
* Returns the hour for time, in 24-hour format, from 0 to 23.
* Importantly, midnight is 0, not 24.
*
* @param string $time Representing the time formatted, like '14:08:12'.
*
* @return int
*/
public function hour( $time ) {
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* H 24-hour format of an hour with leading zeros.
* 00 through 23.
*/
return intval( gmdate( 'H', strtotime( $time ) ) );
}
/**
* Covers MySQL WEEK() function.
*
* Always assumes $mode = 1.
*
* @TODO: Support other modes.
*
* From https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html#function_week:
*
* > Returns the week number for date. The two-argument form of WEEK()
* > enables you to specify whether the week starts on Sunday or Monday
* > and whether the return value should be in the range from 0 to 53
* > or from 1 to 53. If the mode argument is omitted, the value of the
* > default_week_format system variable is used.
* >
* > The following table describes how the mode argument works:
* >
* > Mode First day of week Range Week 1 is the first week
* > 0 Sunday 0-53 with a Sunday in this year
* > 1 Monday 0-53 with 4 or more days this year
* > 2 Sunday 1-53 with a Sunday in this year
* > 3 Monday 1-53 with 4 or more days this year
* > 4 Sunday 0-53 with 4 or more days this year
* > 5 Monday 0-53 with a Monday in this year
* > 6 Sunday 1-53 with 4 or more days this year
* > 7 Monday 1-53 with a Monday in this year
*
* @param string $field Representing the date.
* @param int $mode The mode argument.
*/
public function week( $field, $mode ) {
/*
* From https://www.php.net/manual/en/datetime.format.php:
*
* W - ISO-8601 week number of year, weeks starting on Monday.
* Example: 42 (the 42nd week in the year)
*
* Week 1 is the first week with a Thursday in it.
*/
return intval( gmdate( 'W', strtotime( $field ) ) );
}
/**
* Simulates WEEKDAY() function in MySQL.
*
* Returns the day of the week as an integer.
* The days of the week are numbered 0 to 6:
* * 0 for Monday
* * 1 for Tuesday
* * 2 for Wednesday
* * 3 for Thursday
* * 4 for Friday
* * 5 for Saturday
* * 6 for Sunday
*
* @param string $field Representing the date.
*
* @return int
*/
public function weekday( $field ) {
/*
* date('N') returns 1 (for Monday) through 7 (for Sunday)
* That's one more than MySQL.
* Let's subtract one to make it compatible.
*/
return intval( gmdate( 'N', strtotime( $field ) ) ) - 1;
}
/**
* Method to emulate MySQL DAYOFMONTH() function.
*
* @see https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html#function_dayofmonth
*
* @param string $field Representing the date.
*
* @return int Returns the day of the month for date as a number in the range 1 to 31.
*/
public function dayofmonth( $field ) {
return intval( gmdate( 'j', strtotime( $field ) ) );
}
/**
* Method to emulate MySQL DAYOFWEEK() function.
*
* > Returns the weekday index for date (1 = Sunday, 2 = Monday, , 7 = Saturday).
* > These index values correspond to the ODBC standard. Returns NULL if date is NULL.
*
* @param string $field Representing the date.
*
* @return int Returns the weekday index for date (1 = Sunday, 2 = Monday, , 7 = Saturday).
*/
public function dayofweek( $field ) {
/**
* From https://www.php.net/manual/en/datetime.format.php:
*
* `w` Numeric representation of the day of the week
* 0 (for Sunday) through 6 (for Saturday)
*/
return intval( gmdate( 'w', strtotime( $field ) ) ) + 1;
}
/**
* Method to emulate MySQL DATE() function.
*
* @see https://www.php.net/manual/en/datetime.format.php
*
* @param string $date formatted as unix time.
*
* @return string formatted as '0000-00-00'.
*/
public function date( $date ) {
return gmdate( 'Y-m-d', strtotime( $date ) );
}
/**
* Method to emulate MySQL ISNULL() function.
*
* This function returns true if the argument is null, and true if not.
*
* @param mixed $field The field to be tested.
*
* @return boolean
*/
public function isnull( $field ) {
return is_null( $field );
}
/**
* Method to emulate MySQL IF() function.
*
* As 'IF' is a reserved word for PHP, function name must be changed.
*
* @param mixed $expression The statement to be evaluated as true or false.
* @param mixed $truthy Statement or value returned if $expression is true.
* @param mixed $falsy Statement or value returned if $expression is false.
*
* @return mixed
*/
public function _if( $expression, $truthy, $falsy ) {
return ( true === $expression ) ? $truthy : $falsy;
}
/**
* Method to emulate MySQL REGEXP() function.
*
* @param string $pattern Regular expression to match.
* @param string $field Haystack.
*
* @return integer 1 if matched, 0 if not matched.
*/
public function regexp( $pattern, $field ) {
/*
* If the original query says REGEXP BINARY
* the comparison is byte-by-byte and letter casing now
* matters since lower- and upper-case letters have different
* byte codes.
*
* The REGEXP function can't be easily made to accept two
* parameters, so we'll have to use a hack to get around this.
*
* If the first character of the pattern is a null byte, we'll
* remove it and make the comparison case-sensitive. This should
* be reasonably safe since PHP does not allow null bytes in
* regular expressions anyway.
*/
if ( "\x00" === $pattern[0] ) {
$pattern = substr( $pattern, 1 );
$flags = '';
} else {
// Otherwise, the search is case-insensitive.
$flags = 'i';
}
$pattern = str_replace( '/', '\/', $pattern );
$pattern = '/' . $pattern . '/' . $flags;
return preg_match( $pattern, $field );
}
/**
* Method to emulate MySQL FIELD() function.
*
* This function gets the list argument and compares the first item to all the others.
* If the same value is found, it returns the position of that value. If not, it
* returns 0.
*
* @return int
*/
public function field() {
$num_args = func_num_args();
if ( $num_args < 2 || is_null( func_get_arg( 0 ) ) ) {
return 0;
}
$arg_list = func_get_args();
$search_string = strtolower( array_shift( $arg_list ) );
for ( $i = 0; $i < $num_args - 1; $i++ ) {
if ( strtolower( $arg_list[ $i ] ) === $search_string ) {
return $i + 1;
}
}
return 0;
}
/**
* Method to emulate MySQL LOG() function.
*
* Used with one argument, it returns the natural logarithm of X.
* <code>
* LOG(X)
* </code>
* Used with two arguments, it returns the natural logarithm of X base B.
* <code>
* LOG(B, X)
* </code>
* In this case, it returns the value of log(X) / log(B).
*
* Used without an argument, it returns false. This returned value will be
* rewritten to 0, because SQLite doesn't understand true/false value.
*
* @return double|null
*/
public function log() {
$num_args = func_num_args();
if ( 1 === $num_args ) {
$arg1 = func_get_arg( 0 );
return log( $arg1 );
}
if ( 2 === $num_args ) {
$arg1 = func_get_arg( 0 );
$arg2 = func_get_arg( 1 );
return log( $arg1 ) / log( $arg2 );
}
return null;
}
/**
* Method to emulate MySQL LEAST() function.
*
* This function rewrites the function name to SQLite compatible function name.
*
* @return mixed
*/
public function least() {
$arg_list = func_get_args();
return min( $arg_list );
}
/**
* Method to emulate MySQL GREATEST() function.
*
* This function rewrites the function name to SQLite compatible function name.
*
* @return mixed
*/
public function greatest() {
$arg_list = func_get_args();
return max( $arg_list );
}
/**
* Method to dummy out MySQL GET_LOCK() function.
*
* This function is meaningless in SQLite, so we do nothing.
*
* @param string $name Not used.
* @param integer $timeout Not used.
*
* @return string
*/
public function get_lock( $name, $timeout ) {
return '1=1';
}
/**
* Method to dummy out MySQL RELEASE_LOCK() function.
*
* This function is meaningless in SQLite, so we do nothing.
*
* @param string $name Not used.
*
* @return string
*/
public function release_lock( $name ) {
return '1=1';
}
/**
* Method to emulate MySQL UCASE() function.
*
* This is MySQL alias for upper() function. This function rewrites it
* to SQLite compatible name upper().
*
* @param string $content String to be converted to uppercase.
*
* @return string SQLite compatible function name.
*/
public function ucase( $content ) {
return "upper($content)";
}
/**
* Method to emulate MySQL LCASE() function.
*
* This is MySQL alias for lower() function. This function rewrites it
* to SQLite compatible name lower().
*
* @param string $content String to be converted to lowercase.
*
* @return string SQLite compatible function name.
*/
public function lcase( $content ) {
return "lower($content)";
}
/**
* Method to emulate MySQL UNHEX() function.
*
* For a string argument str, UNHEX(str) interprets each pair of characters
* in the argument as a hexadecimal number and converts it to the byte represented
* by the number. The return value is a binary string.
*
* @param string $number Number to be unhexed.
*
* @return string Binary string
*/
public function unhex( $number ) {
return pack( 'H*', $number );
}
/**
* Method to emulate MySQL FROM_BASE64() function.
*
* Takes a base64-encoded string and returns the decoded result as a binary
* string. Returns NULL if the argument is NULL or is not a valid base64 string.
*
* @param string|null $str The base64-encoded string.
*
* @return string|null Decoded binary string, or NULL.
*/
public function from_base64( $str ) {
if ( null === $str ) {
return null;
}
$decoded = base64_decode( $str, true );
if ( false === $decoded ) {
return null;
}
return $decoded;
}
/**
* Method to emulate MySQL TO_BASE64() function.
*
* Takes a string and returns a base64-encoded result.
* Returns NULL if the argument is NULL.
*
* @param string|null $str The string to encode.
*
* @return string|null Base64-encoded string, or NULL.
*/
public function to_base64( $str ) {
if ( null === $str ) {
return null;
}
return base64_encode( $str );
}
/**
* Method to emulate MySQL INET_NTOA() function.
*
* This function gets 4 or 8 bytes integer and turn it into the network address.
*
* @param integer $num Long integer.
*
* @return string
*/
public function inet_ntoa( $num ) {
return long2ip( $num );
}
/**
* Method to emulate MySQL INET_ATON() function.
*
* This function gets the network address and turns it into integer.
*
* @param string $addr Network address.
*
* @return int long integer
*/
public function inet_aton( $addr ) {
return absint( ip2long( $addr ) );
}
/**
* Method to emulate MySQL DATEDIFF() function.
*
* This function compares two dates value and returns the difference.
*
* @param string $start Start date.
* @param string $end End date.
*
* @return string
*/
public function datediff( $start, $end ) {
$start_date = new DateTime( $start );
$end_date = new DateTime( $end );
$interval = $end_date->diff( $start_date, false );
return $interval->format( '%r%a' );
}
/**
* Method to emulate MySQL LOCATE() function.
*
* This function returns the position if $substr is found in $str. If not,
* it returns 0. If mbstring extension is loaded, mb_strpos() function is
* used.
*
* @param string $substr Needle.
* @param string $str Haystack.
* @param integer $pos Position.
*
* @return integer
*/
public function locate( $substr, $str, $pos = 0 ) {
if ( ! extension_loaded( 'mbstring' ) ) {
$val = strpos( $str, $substr, $pos );
if ( false !== $val ) {
return $val + 1;
}
return 0;
}
$val = mb_strpos( $str, $substr, $pos );
if ( false !== $val ) {
return $val + 1;
}
return 0;
}
/**
* Method to return GMT date in the string format.
*
* @return string formatted GMT date 'dddd-mm-dd'
*/
public function utc_date() {
return gmdate( 'Y-m-d', time() );
}
/**
* Method to return GMT time in the string format.
*
* @return string formatted GMT time '00:00:00'
*/
public function utc_time() {
return gmdate( 'H:i:s', time() );
}
/**
* Method to return GMT time stamp in the string format.
*
* @return string formatted GMT timestamp 'yyyy-mm-dd 00:00:00'
*/
public function utc_timestamp() {
return gmdate( 'Y-m-d H:i:s', time() );
}
/**
* Method to return MySQL version.
*
* This function only returns the current newest version number of MySQL,
* because it is meaningless for SQLite database.
*
* @return string representing the version number: major_version.minor_version
*/
public function version() {
return '5.5';
}
/**
* A helper to covert LIKE pattern to a GLOB pattern for "LIKE BINARY" support.
* @TODO: Some of the MySQL string specifics described below are likely to
* affect also other patterns than just "LIKE BINARY". We should
* consider applying some of the conversions more broadly.
*
* @param string $pattern
* @return string
*/
public function _helper_like_to_glob_pattern( $pattern ) {
if ( null === $pattern ) {
return null;
}
/*
* 1. Escape characters that have special meaning in GLOB patterns.
*
* We need to:
* 1. Escape "]" as "[]]" to avoid interpreting "[...]" as a character class.
* 2. Escape "*" as "[*]" (must be after 1 to avoid being escaped).
* 3. Escape "?" as "[?]" (must be after 1 to avoid being escaped).
*/
$pattern = str_replace( ']', '[]]', $pattern );
$pattern = str_replace( '*', '[*]', $pattern );
$pattern = str_replace( '?', '[?]', $pattern );
/*
* 2. Convert LIKE wildcards to GLOB wildcards ("%" -> "*", "_" -> "?").
*
* We need to convert them only when they don't follow any backslashes,
* or when they follow an even number of backslashes (as "\\" is "\").
*/
$pattern = preg_replace( '/(^|[^\\\\](?:\\\\{2})*)%/', '$1*', $pattern );
$pattern = preg_replace( '/(^|[^\\\\](?:\\\\{2})*)_/', '$1?', $pattern );
/*
* 3. Unescape LIKE escape sequences.
*
* While in MySQL LIKE patterns, a backslash is usually used to escape
* special characters ("%", "_", and "\"), it works with all characters.
*
* That is:
* SELECT '\\x' prints '\x', but LIKE '\\x' is equivalent to LIKE 'x'.
*
* This is true also for multi-byte characters:
* SELECT '\\©' prints '\©', but LIKE '\\©' is equivalent to LIKE '©'.
*
* However, the multi-byte behavior is likely to depend on the charset.
* For now, we'll assume UTF-8 and thus the "u" modifier for the regex.
*/
$pattern = preg_replace( '/\\\\(.)/u', '$1', $pattern );
return $pattern;
}
}
@@ -0,0 +1,8 @@
<?php
/**
* The version of the SQLite driver.
*
* This constant needs to be updated on plugin release!
*/
define( 'SQLITE_DRIVER_VERSION', '3.0.0-rc.4' );
@@ -0,0 +1,127 @@
<?php
class WP_SQLite_Crosscheck_DB_ extends WP_SQLite_DB {
public function __construct( string $dbname ) {
parent::__construct( $dbname );
$GLOBALS['sqlite'] = $this;
$GLOBALS['mysql'] = new wpdb(
DB_USER,
DB_PASSWORD,
DB_NAME,
DB_HOST
);
// $this->resetDatabases();
}
private function resetDatabases() {
if ( file_exists( FQDB ) ) {
unlink( FQDB );
}
$GLOBALS['mysql']->query( 'DROP DATABASE IF EXISTS ' . DB_NAME );
$GLOBALS['mysql']->query( 'CREATE DATABASE ' . DB_NAME );
$GLOBALS['mysql']->query( 'USE ' . DB_NAME );
}
public function query( $query ) {
/**
* In MySQL, AUTO_INCREMENT columns don't reuse IDs assigned in rollback transactions
* In SQLite, AUTOINCREMENT columns do reuse IDs assigned in rollback transactions
*
* Let's store the current AUTOINCREMENT value for each table, and restore it afterwards.
*/
if ( preg_match( '/^\s*rollback/i', $query ) ) {
$autoincrements = array();
$tables = $GLOBALS['@pdo']->query( "SELECT name as `table` FROM sqlite_master WHERE type='table' ORDER BY name" )->fetchAll();
foreach ( $tables as $table ) {
$table = $table['table'];
$autoincrement = $GLOBALS['@pdo']->query( "SELECT seq FROM sqlite_sequence WHERE name = '$table'" )->fetchColumn();
$autoincrements[ $table ] = $autoincrement ?: 1;
}
}
$sqlite_retval = parent::query( $query );
if ( preg_match( '/^\s*rollback/i', $query ) ) {
foreach ( $autoincrements as $table => $autoincrement ) {
$GLOBALS['@pdo']->query( "UPDATE sqlite_sequence SET seq = $autoincrement WHERE name = '$table'" );
}
}
$this->crosscheck( $query, $sqlite_retval );
return $sqlite_retval;
}
private function crosscheck( $query, $sqlite_retval ) {
// echo $query."\n\n";
// Be lenient on cross-checking some query types
if ( preg_match( '/^\s*SET storage_engine/i', $query ) ) {
return;
}
$this->show_errors = false;
$this->suppress_errors = true;
$GLOBALS['mysql']->show_errors = false;
$GLOBALS['mysql']->suppress_errors = true;
ob_start();
$mysql_retval = $GLOBALS['mysql']->query( $query );
ob_end_clean();
$tests = array(
array( 'retval', $mysql_retval, $sqlite_retval ),
array( 'num_rows', $GLOBALS['mysql']->num_rows, $GLOBALS['sqlite']->num_rows ),
array( 'insert_id', $GLOBALS['mysql']->insert_id, $GLOBALS['sqlite']->insert_id ),
array( 'rows_affected', $GLOBALS['mysql']->rows_affected, $GLOBALS['sqlite']->rows_affected ),
);
foreach ( $tests as $test ) {
list($factor, $mysql, $sqlite) = $test;
if ( $mysql !== $sqlite ) {
if ( 'insert_id' === $factor ) {
// On multi-inserts MySQL returns the first inserted ID
// while SQLite returns the last one. The cached insert_id
// value stays the same for a number of subsequent queries.
// Let's forgive this for now.
continue;
}
if ( 'rows_affected' === $factor && $mysql_retval === $mysql ) {
// SQLite doesn't provide the rowcount() functionality
continue;
}
if ( 'retval' === $factor && $GLOBALS['mysql']->rows_affected === $mysql ) {
// SQLite doesn't provide the rowcount() functionality
continue;
}
echo "======================================================\n";
echo "======== *** $factor *** differed for query ========= \n";
echo "======================================================\n";
echo "MySQL query: \n";
echo "$query\n\n";
echo "SQLite queries: \n";
foreach ( $this->dbh->last_translation->queries as $query ) {
echo $query->sql . "\n";
}
echo "\n";
$this->report_factor(
'error',
$GLOBALS['mysql']->last_error,
$GLOBALS['sqlite']->last_error
);
foreach ( $tests as $test ) {
$this->report_factor(
$test[0],
$test[1],
$test[2]
);
}
// throw new Exception();
break;
}
}
}
private function report_factor( $factor, $mysql, $sqlite ) {
echo "$factor: \n";
echo ' MySQL: ' . var_export( $mysql, true ) . "\n";
echo ' SQLite: ' . var_export( $sqlite, true ) . "\n\n";
}
}
@@ -0,0 +1,675 @@
<?php
/**
* Extend and replace the wpdb class.
*
* @package wp-sqlite-integration
* @since 1.0.0
*/
/**
* This class extends wpdb and replaces it.
*
* It also rewrites some methods that use mysql specific functions.
*/
class WP_SQLite_DB extends wpdb {
/**
* Database Handle
*
* @var WP_SQLite_Driver
*/
protected $dbh;
/**
* Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters.
*
* This property is mirroring "wpdb::$allow_unsafe_unquoted_parameters",
* because some tests are accessing it externally using PHP reflection.
*
* @var
*/
private $allow_unsafe_unquoted_parameters = true;
/**
* Connects to the SQLite database.
*
* Unlike for MySQL, no credentials and host are needed.
*
* @param string $dbname Database name.
*/
public function __construct( $dbname ) {
/**
* We need to initialize the "$wpdb" global early, so that the SQLite
* driver can configure the database. The call stack goes like this:
*
* 1. The "parent::__construct()" call executes "$this->db_connect()".
* 2. The database connection call initializes the SQLite driver.
* 3. The SQLite driver initializes and runs "WP_SQLite_Configurator".
* 4. The configurator uses "WP_SQLite_Information_Schema_Reconstructor",
* which requires "wp-admin/includes/schema.php" when in WordPress.
* 5. The "wp-admin/includes/schema.php" requires the "$wpdb" global,
* which creates a circular dependency.
*/
$GLOBALS['wpdb'] = $this;
parent::__construct( '', '', $dbname, '' );
$this->charset = 'utf8mb4';
}
/**
* Method to set character set for the database.
*
* This overrides wpdb::set_charset(), only to dummy out the MySQL function.
*
* @see wpdb::set_charset()
*
* @param resource $dbh The resource given by mysql_connect.
* @param string $charset Optional. The character set. Default null.
* @param string $collate Optional. The collation. Default null.
*/
public function set_charset( $dbh, $charset = null, $collate = null ) {
}
/**
* Method to get the character set for the database.
* Hardcoded to utf8mb4 for now.
*
* @param string $table The table name.
* @param string $column The column name.
*
* @return string The character set.
*/
public function get_col_charset( $table, $column ) {
// Hardcoded for now.
return 'utf8mb4';
}
/**
* Changes the current SQL mode, and ensures its WordPress compatibility.
*
* If no modes are passed, it will ensure the current MySQL server modes are compatible.
*
* This overrides wpdb::set_sql_mode() while closely mirroring its implementation.
*
* @param array $modes Optional. A list of SQL modes to set. Default empty array.
*/
public function set_sql_mode( $modes = array() ) {
if ( empty( $modes ) ) {
$result = $this->dbh->query( 'SELECT @@SESSION.sql_mode' );
if ( ! isset( $result[0] ) ) {
return;
}
$modes_str = $result[0]->{'@@SESSION.sql_mode'};
if ( empty( $modes_str ) ) {
return;
}
$modes = explode( ',', $modes_str );
}
$modes = array_change_key_case( $modes, CASE_UPPER );
/**
* Filters the list of incompatible SQL modes to exclude.
*
* @since 3.9.0
*
* @param array $incompatible_modes An array of incompatible modes.
*/
$incompatible_modes = (array) apply_filters( 'incompatible_sql_modes', $this->incompatible_modes );
foreach ( $modes as $i => $mode ) {
if ( in_array( $mode, $incompatible_modes, true ) ) {
unset( $modes[ $i ] );
}
}
$modes_str = implode( ',', $modes );
$this->dbh->query( "SET SESSION sql_mode='$modes_str'" );
}
/**
* Closes the current database connection.
* Noop in SQLite.
*
* @return bool True to indicate the connection was successfully closed.
*/
public function close() {
return true;
}
/**
* Method to select the database connection.
*
* This overrides wpdb::select(), only to dummy out the MySQL function.
*
* @see wpdb::select()
*
* @param string $db MySQL database name. Not used.
* @param resource|null $dbh Optional link identifier.
*/
public function select( $db, $dbh = null ) {
$this->ready = true;
}
/**
* Method to escape characters.
*
* This overrides wpdb::_real_escape() to avoid using mysql_real_escape_string().
*
* @see wpdb::_real_escape()
*
* @param string $data The string to escape.
*
* @return string escaped
*/
public function _real_escape( $data ) {
if ( ! is_scalar( $data ) ) {
return '';
}
$escaped = addslashes( $data );
return $this->add_placeholder_escape( $escaped );
}
/**
* Prints SQL/DB error.
*
* This overrides wpdb::print_error() while closely mirroring its implementation.
*
* @global array $EZSQL_ERROR Stores error information of query and error string.
*
* @param string $str The error to display.
* @return void|false Void if the showing of errors is enabled, false if disabled.
*/
public function print_error( $str = '' ) {
global $EZSQL_ERROR;
if ( ! $str ) {
$str = $this->last_error;
}
$EZSQL_ERROR[] = array(
'query' => $this->last_query,
'error_str' => $str,
);
if ( $this->suppress_errors ) {
return false;
}
$caller = $this->get_caller();
if ( $caller ) {
// Not translated, as this will only appear in the error log.
$error_str = sprintf( 'WordPress database error %1$s for query %2$s made by %3$s', $str, $this->last_query, $caller );
} else {
$error_str = sprintf( 'WordPress database error %1$s for query %2$s', $str, $this->last_query );
}
error_log( $error_str );
// Are we showing errors?
if ( ! $this->show_errors ) {
return false;
}
wp_load_translations_early();
// If there is an error then take note of it.
if ( is_multisite() ) {
$msg = sprintf(
"%s [%s]\n%s\n",
__( 'WordPress database error:' ),
$str,
$this->last_query
);
if ( defined( 'ERRORLOGFILE' ) ) {
error_log( $msg, 3, ERRORLOGFILE );
}
if ( defined( 'DIEONDBERROR' ) ) {
wp_die( $msg );
}
} else {
$str = htmlspecialchars( $str, ENT_QUOTES );
$query = htmlspecialchars( $this->last_query, ENT_QUOTES );
printf(
'<div id="error"><p class="wpdberror"><strong>%s</strong> [%s]<br /><code>%s</code></p></div>',
__( 'WordPress database error:' ),
$str,
$query
);
}
}
/**
* Method to flush cached data.
*
* This overrides wpdb::flush(). This is not necessarily overridden, because
* $result will never be resource.
*
* @see wpdb::flush
*/
public function flush() {
$this->last_result = array();
$this->col_info = null;
$this->last_query = null;
$this->rows_affected = 0;
$this->num_rows = 0;
$this->last_error = '';
$this->result = null;
}
/**
* Method to do the database connection.
*
* This overrides wpdb::db_connect() to avoid using MySQL function.
*
* @see wpdb::db_connect()
*
* @param bool $allow_bail Not used.
* @return void
*/
public function db_connect( $allow_bail = true ) {
if ( $this->dbh ) {
return;
}
$this->init_charset();
$pdo = null;
if ( isset( $GLOBALS['@pdo'] ) ) {
$pdo = $GLOBALS['@pdo'];
}
// Migrate the database file from a legacy path, if it exists.
if ( ! defined( 'DB_FILE' ) && ! file_exists( FQDB ) ) {
$old_db_path = FQDBDIR . '.ht.sqlite.php';
if ( file_exists( $old_db_path ) ) {
if ( ! rename( $old_db_path, FQDB ) ) {
wp_die( 'Failed to rename database file.', 'Error!' );
}
foreach ( array( '-wal', '-shm', '-journal' ) as $suffix ) {
if ( file_exists( $old_db_path . $suffix ) ) {
if ( ! rename( $old_db_path . $suffix, FQDB . $suffix ) ) {
wp_die( 'Failed to rename database file.', 'Error!' );
}
}
}
}
}
if ( null === $this->dbname || '' === $this->dbname ) {
$this->bail(
'The database name was not set. The SQLite driver requires a database name to be set to emulate MySQL information schema tables.',
'db_connect_fail'
);
return false;
}
$this->ensure_database_directory( FQDB );
try {
$connection = new WP_SQLite_Connection(
array(
'pdo' => $pdo,
'path' => FQDB,
'journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null,
)
);
$this->dbh = new WP_SQLite_Driver( $connection, $this->dbname );
$GLOBALS['@pdo'] = $this->dbh->get_connection()->get_pdo();
} catch ( Throwable $e ) {
$this->last_error = $this->format_error_message( $e );
}
if ( $this->last_error ) {
return false;
}
$this->ready = true;
$this->set_sql_mode();
}
/**
* Method to dummy out wpdb::check_connection()
*
* @param bool $allow_bail Not used.
*
* @return bool
*/
public function check_connection( $allow_bail = true ) {
return true;
}
/**
* Prepares a SQL query for safe execution.
*
* See "wpdb::prepare()". This override only fixes a WPDB test issue.
*
* @param string $query Query statement with `sprintf()`-like placeholders.
* @param array|mixed $args The array of variables or the first variable to substitute.
* @param mixed ...$args Further variables to substitute when using individual arguments.
* @return string|void Sanitized query string, if there is a query to prepare.
*/
public function prepare( $query, ...$args ) {
/*
* Sync "$allow_unsafe_unquoted_parameters" with the WPDB parent property.
* This is only needed because some WPDB tests are accessing the private
* property externally via PHP reflection. This should be fixed WP tests.
*/
$wpdb_allow_unsafe_unquoted_parameters = $this->__get( 'allow_unsafe_unquoted_parameters' );
if ( $wpdb_allow_unsafe_unquoted_parameters !== $this->allow_unsafe_unquoted_parameters ) {
$property = new ReflectionProperty( 'wpdb', 'allow_unsafe_unquoted_parameters' );
$property->setAccessible( true );
$property->setValue( $this, $this->allow_unsafe_unquoted_parameters );
$property->setAccessible( false );
}
return parent::prepare( $query, ...$args );
}
/**
* Performs a database query.
*
* This overrides wpdb::query() while closely mirroring its implementation.
*
* @see wpdb::query()
*
* @param string $query Database query.
*
* @param string $query Database query.
* @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. Number of rows
* affected/selected for all other queries. Boolean false on error.
*/
public function query( $query ) {
// Query Monitor integration:
$query_monitor_active = defined( 'SQLITE_QUERY_MONITOR_LOADED' ) && SQLITE_QUERY_MONITOR_LOADED;
if ( $query_monitor_active && $this->show_errors ) {
$this->hide_errors();
}
if ( ! $this->ready ) {
return false;
}
$query = apply_filters( 'query', $query );
if ( ! $query ) {
$this->insert_id = 0;
return false;
}
$this->flush();
// Log how the function was called.
$this->func_call = "\$db->query(\"$query\")";
// Keep track of the last query for debug.
$this->last_query = $query;
// Save the query count before running another query.
$last_query_count = count( $this->queries ?? array() );
/*
* @TODO: WPDB uses "$this->check_current_query" to check table/column
* charset and strip all invalid characters from the query.
* This is an involved process that we can bypass for SQLite,
* if we simply strip all invalid UTF-8 characters from the query.
*
* To do so, mb_convert_encoding can be used with an optional
* fallback to a htmlspecialchars method. E.g.:
* https://github.com/nette/utils/blob/be534713c227aeef57ce1883fc17bc9f9e29eca2/src/Utils/Strings.php#L42
*/
$this->_do_query( $query );
if ( $this->last_error ) {
// Clear insert_id on a subsequent failed insert.
if ( $this->insert_id && preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
$this->insert_id = 0;
}
$this->print_error();
return false;
}
if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query ) ) {
$return_val = true;
} elseif ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) {
$this->rows_affected = $this->dbh->get_last_return_value();
// Take note of the insert_id.
if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
$this->insert_id = $this->dbh->get_insert_id();
}
// Return number of rows affected.
$return_val = $this->rows_affected;
} else {
$num_rows = 0;
if ( is_array( $this->result ) ) {
$this->last_result = $this->result;
$num_rows = count( $this->result );
}
// Log and return the number of rows selected.
$this->num_rows = $num_rows;
$return_val = $num_rows;
}
// Query monitor integration:
if ( $query_monitor_active && class_exists( 'QM_Backtrace' ) ) {
if ( did_action( 'qm/cease' ) ) {
$this->queries = array();
}
$i = $last_query_count;
if ( ! isset( $this->queries[ $i ] ) ) {
return $return_val;
}
$this->queries[ $i ]['trace'] = new QM_Backtrace();
if ( ! isset( $this->queries[ $i ][3] ) ) {
$this->queries[ $i ][3] = $this->time_start;
}
if ( $this->last_error && ! $this->suppress_errors ) {
$this->queries[ $i ]['result'] = new WP_Error( 'qmdb', $this->last_error );
} else {
$this->queries[ $i ]['result'] = (int) $return_val;
}
// Add SQLite query data.
$this->queries[ $i ]['sqlite_queries'] = $this->dbh->get_last_sqlite_queries();
}
return $return_val;
}
/**
* Internal function to perform the SQLite query call.
*
* This closely mirrors wpdb::_do_query().
*
* @see wpdb::_do_query()
*
* @param string $query The query to run.
*/
private function _do_query( $query ) {
if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
$this->timer_start();
}
try {
$this->result = $this->dbh->query( $query );
} catch ( Throwable $e ) {
$this->last_error = $this->format_error_message( $e );
}
++$this->num_queries;
if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
$this->log_query(
$query,
$this->timer_stop(),
$this->get_caller(),
$this->time_start,
array()
);
}
}
/**
* Method to set the class variable $col_info.
*
* This overrides wpdb::load_col_info(), which uses a mysql function.
*
* @see wpdb::load_col_info()
*/
protected function load_col_info() {
if ( $this->col_info ) {
return;
}
$this->col_info = array();
foreach ( $this->dbh->get_last_column_meta() as $column ) {
$this->col_info[] = (object) array(
'name' => $column['name'],
'orgname' => $column['mysqli:orgname'],
'table' => $column['table'],
'orgtable' => $column['mysqli:orgtable'],
'def' => '', // Unused, always ''.
'db' => $column['mysqli:db'],
'catalog' => 'def', // Unused, always 'def'.
'max_length' => 0, // As of PHP 8.1, this is always 0.
'length' => $column['len'],
'charsetnr' => $column['mysqli:charsetnr'],
'flags' => $column['mysqli:flags'],
'type' => $column['mysqli:type'],
'decimals' => $column['precision'],
);
}
}
/**
* Method to return what the database can do.
*
* This overrides wpdb::has_cap() to avoid using MySQL functions.
* SQLite supports subqueries, but not support collation, group_concat and set_charset.
*
* @see wpdb::has_cap()
*
* @param string $db_cap The feature to check for. Accepts 'collation',
* 'group_concat', 'subqueries', 'set_charset',
* 'utf8mb4', or 'utf8mb4_520'.
*
* @return bool Whether the database feature is supported, false otherwise.
*/
public function has_cap( $db_cap ) {
return 'subqueries' === strtolower( $db_cap );
}
/**
* Method to return database version number.
*
* This overrides wpdb::db_version() to avoid using MySQL function.
* It returns mysql version number, but it means nothing for SQLite.
* So it return the newest mysql version.
*
* @see wpdb::db_version()
*/
public function db_version() {
return '8.0';
}
/**
* Returns the version of the SQLite engine.
*
* @return string SQLite engine version as a string.
*/
public function db_server_info() {
return $this->dbh->get_sqlite_version();
}
/**
* Make sure the SQLite database directory exists and is writable.
* Create .htaccess and index.php files to prevent direct access.
*
* @param string $database_path The path to the SQLite database file.
*/
private function ensure_database_directory( string $database_path ) {
$dir = dirname( $database_path );
// Set the umask to 0000 to apply permissions exactly as specified.
// A non-zero umask affects new file and directory permissions.
$umask = umask( 0 );
// Ensure database directory.
if ( ! is_dir( $dir ) ) {
if ( ! @mkdir( $dir, 0700, true ) ) {
wp_die( sprintf( 'Failed to create database directory: %s', $dir ), 'Error!' );
}
}
if ( ! is_writable( $dir ) ) {
wp_die( sprintf( 'Database directory is not writable: %s', $dir ), 'Error!' );
}
// Ensure .htaccess file to prevent direct access.
$path = $dir . DIRECTORY_SEPARATOR . '.htaccess';
if ( ! is_file( $path ) ) {
$result = file_put_contents( $path, 'DENY FROM ALL', LOCK_EX );
if ( false === $result ) {
wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
}
chmod( $path, 0600 );
}
// Ensure index.php file to prevent direct access.
$path = $dir . DIRECTORY_SEPARATOR . 'index.php';
if ( ! is_file( $path ) ) {
$result = file_put_contents( $path, '<?php // Silence is gold. ?>', LOCK_EX );
if ( false === $result ) {
wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
}
chmod( $path, 0600 );
}
// Restore the original umask value.
umask( $umask );
}
/**
* Format SQLite driver error message.
*
* @return string
*/
private function format_error_message( Throwable $e ) {
$output = '<div style="clear:both">&nbsp;</div>' . PHP_EOL;
// Queries.
if ( $e instanceof WP_SQLite_Driver_Exception ) {
$driver = $e->getDriver();
$output .= '<div class="queries" style="clear:both;margin-bottom:2px;border:red dotted thin;">' . PHP_EOL;
$output .= '<p>MySQL query:</p>' . PHP_EOL;
$output .= '<p>' . $driver->get_last_mysql_query() . '</p>' . PHP_EOL;
$output .= '<p>Queries made or created this session were:</p>' . PHP_EOL;
$output .= '<ol>' . PHP_EOL;
foreach ( $driver->get_last_sqlite_queries() as $q ) {
$message = "Executing: {$q['sql']} | " . ( $q['params'] ? 'parameters: ' . implode( ', ', $q['params'] ) : '(no parameters)' );
$output .= '<li>' . htmlspecialchars( $message ) . '</li>' . PHP_EOL;
}
$output .= '</ol>' . PHP_EOL;
$output .= '</div>' . PHP_EOL;
}
// Message.
$output .= '<div style="clear:both;margin-bottom:2px;border:red dotted thin;" class="error_message" style="border-bottom:dotted blue thin;">' . PHP_EOL;
$output .= $e->getMessage() . PHP_EOL;
$output .= '</div>' . PHP_EOL;
// Backtrace.
$output .= '<p>Backtrace:</p>' . PHP_EOL;
$output .= '<pre>' . $e->getTraceAsString() . '</pre>' . PHP_EOL;
return $output;
}
}
@@ -0,0 +1,71 @@
<?php
/**
* Main integration file.
*
* @package wp-sqlite-integration
* @since 1.0.0
*/
/**
* Load the "SQLITE_DRIVER_VERSION" constant.
*/
require_once __DIR__ . '/../database/version.php';
// Require the constants file.
require_once __DIR__ . '/../../constants.php';
// Bail early if DB_ENGINE is not defined as sqlite.
if ( ! defined( 'DB_ENGINE' ) || 'sqlite' !== DB_ENGINE ) {
return;
}
if ( ! extension_loaded( 'pdo' ) ) {
wp_die(
new WP_Error(
'pdo_not_loaded',
sprintf(
'<h1>%1$s</h1><p>%2$s</p>',
'PHP PDO Extension is not loaded',
'Your PHP installation appears to be missing the PDO extension which is required for this version of WordPress and the type of database you have specified.'
)
),
'PHP PDO Extension is not loaded.'
);
}
if ( ! extension_loaded( 'pdo_sqlite' ) ) {
wp_die(
new WP_Error(
'pdo_driver_not_loaded',
sprintf(
'<h1>%1$s</h1><p>%2$s</p>',
'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.'
);
}
require_once __DIR__ . '/../database/load.php';
require_once __DIR__ . '/class-wp-sqlite-db.php';
require_once __DIR__ . '/install-functions.php';
$db_name = defined( 'DB_NAME' ) ? DB_NAME : '';
/*
* Debug: Cross-check with MySQL.
* This is for debugging purpose only and requires files
* that are present in the GitHub repository
* but not the plugin published on WordPress.org.
*/
$crosscheck_tests_file_path = __DIR__ . '/class-wp-sqlite-crosscheck-db.php';
if ( defined( 'SQLITE_DEBUG_CROSSCHECK' ) && SQLITE_DEBUG_CROSSCHECK && file_exists( $crosscheck_tests_file_path ) ) {
require_once $crosscheck_tests_file_path;
$GLOBALS['wpdb'] = new WP_SQLite_Crosscheck_DB( $db_name );
} else {
$GLOBALS['wpdb'] = new WP_SQLite_DB( $db_name );
// Boot the Query Monitor plugin if it is active.
require_once __DIR__ . '/../../integrations/query-monitor/boot.php';
}
@@ -0,0 +1,232 @@
<?php
/**
* Main integration file.
*
* @package wp-sqlite-integration
* @since 1.0.0
*/
/**
* Function to create tables according to the schemas of WordPress.
*
* This is executed only once while installation.
*
* @since 1.0.0
*
* @return boolean
*
* @throws PDOException If the database connection fails.
*/
function sqlite_make_db_sqlite() {
global $wpdb;
include_once ABSPATH . 'wp-admin/includes/schema.php';
$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!<br />';
$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;
try {
$translator->begin_transaction();
foreach ( $queries as $query ) {
$query = trim( $query );
if ( empty( $query ) ) {
continue;
}
$translator->query( $query );
}
$translator->commit();
} catch ( PDOException $err ) {
$translator->rollback();
$message = sprintf(
'Error occurred while creating tables or indexes...<br />Query was: %s<br />',
var_export( $query, true )
);
$message .= sprintf( 'Error message is: %s', $err->getMessage() );
wp_die( $message, 'Database Error!' );
}
/*
* Debug: Cross-check with MySQL.
* This is for debugging purpose only and requires files
* that are present in the GitHub repository
* but not the plugin published on WordPress.org.
*/
if ( defined( 'SQLITE_DEBUG_CROSSCHECK' ) && SQLITE_DEBUG_CROSSCHECK ) {
$host = DB_HOST;
$port = 3306;
if ( str_contains( $host, ':' ) ) {
$host_parts = explode( ':', $host );
$host = $host_parts[0];
$port = $host_parts[1];
}
$dsn = 'mysql:host=' . $host . '; port=' . $port . '; dbname=' . DB_NAME;
$pdo_class = PHP_VERSION_ID >= 80400 ? PDO\MySQL::class : PDO::class; // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
$pdo_mysql = new $pdo_class( $dsn, DB_USER, DB_PASSWORD, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO
$pdo_mysql->query( 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";' );
$pdo_mysql->query( 'SET time_zone = "+00:00";' );
foreach ( $queries as $query ) {
$query = trim( $query );
if ( empty( $query ) ) {
continue;
}
try {
$pdo_mysql->beginTransaction();
$pdo_mysql->query( $query );
} catch ( PDOException $err ) {
$err_data = $err->errorInfo; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$err_code = $err_data[1];
// phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
if ( 5 == $err_code || 6 == $err_code ) {
// If the database is locked, commit again.
$pdo_mysql->commit();
} else {
$pdo_mysql->rollBack();
$message = sprintf(
'Error occurred while creating tables or indexes...<br />Query was: %s<br />',
var_export( $query, true )
);
$message .= sprintf( 'Error message is: %s', $err_data[2] );
wp_die( $message, 'Database Error!' );
}
}
}
}
$pdo = null;
return true;
}
if ( ! function_exists( 'wp_install' ) ) {
/**
* Installs the site.
*
* Runs the required functions to set up and populate the database,
* including primary admin user and initial options.
*
* @since 1.0.0
*
* @param string $blog_title Site title.
* @param string $user_name User's username.
* @param string $user_email User's email.
* @param bool $is_public Whether the site is public.
* @param string $deprecated Optional. Not used.
* @param string $user_password Optional. User's chosen password. Default empty (random password).
* @param string $language Optional. Language chosen. Default empty.
* @return array {
* Data for the newly installed site.
*
* @type string $url The URL of the site.
* @type int $user_id The ID of the site owner.
* @type string $password The password of the site owner, if their user account didn't already exist.
* @type string $password_message The explanatory message regarding the password.
* }
*/
function wp_install( $blog_title, $user_name, $user_email, $is_public, $deprecated = '', $user_password = '', $language = '' ) {
if ( ! empty( $deprecated ) ) {
_deprecated_argument( __FUNCTION__, '2.6.0' );
}
wp_check_mysql_version();
wp_cache_flush();
/* SQLite changes: Replace the call to make_db_current_silent() with sqlite_make_db_sqlite(). */
sqlite_make_db_sqlite(); // phpcs:ignore PHPCompatibility.Extensions.RemovedExtensions.sqliteRemoved
populate_options();
populate_roles();
update_option( 'blogname', $blog_title );
update_option( 'admin_email', $user_email );
update_option( 'blog_public', $is_public );
// Freshness of site - in the future, this could get more specific about actions taken, perhaps.
update_option( 'fresh_site', 1 );
if ( $language ) {
update_option( 'WPLANG', $language );
}
$guessurl = wp_guess_url();
update_option( 'siteurl', $guessurl );
// If not a public site, don't ping.
if ( ! $is_public ) {
update_option( 'default_pingback_flag', 0 );
}
/*
* Create default user. If the user already exists, the user tables are
* being shared among sites. Just set the role in that case.
*/
$user_id = username_exists( $user_name );
$user_password = trim( $user_password );
$email_password = false;
$user_created = false;
if ( ! $user_id && empty( $user_password ) ) {
$user_password = wp_generate_password( 12, false );
$message = __( '<strong><em>Note that password</em></strong> carefully! It is a <em>random</em> password that was generated just for you.', 'sqlite-database-integration' );
$user_id = wp_create_user( $user_name, $user_password, $user_email );
update_user_meta( $user_id, 'default_password_nag', true );
$email_password = true;
$user_created = true;
} elseif ( ! $user_id ) {
// Password has been provided.
$message = '<em>' . __( 'Your chosen password.', 'sqlite-database-integration' ) . '</em>';
$user_id = wp_create_user( $user_name, $user_password, $user_email );
$user_created = true;
} else {
$message = __( 'User already exists. Password inherited.', 'sqlite-database-integration' );
}
$user = new WP_User( $user_id );
$user->set_role( 'administrator' );
if ( $user_created ) {
$user->user_url = $guessurl;
wp_update_user( $user );
}
wp_install_defaults( $user_id );
wp_install_maybe_enable_pretty_permalinks();
flush_rewrite_rules();
wp_new_blog_notification( $blog_title, $guessurl, $user_id, ( $email_password ? $user_password : __( 'The password you chose during installation.', 'sqlite-database-integration' ) ) );
wp_cache_flush();
/**
* Fires after a site is fully installed.
*
* @since 3.9.0
*
* @param WP_User $user The site owner.
*/
do_action( 'wp_install', $user );
return array(
'url' => $guessurl,
'user_id' => $user_id,
'password' => $user_password,
'password_message' => $message,
);
}
}