From 5e7cae9f4b55a8dc9e0d2e3c473eb008048d3133 Mon Sep 17 00:00:00 2001 From: Mika Wenell Date: Tue, 11 Aug 2026 11:02:59 +0300 Subject: [PATCH] fix: Multisite table names, dbDelta schemas, and migration guards Resolve incorrect global users/usermeta prefixing on Multisite subsites, make create_database() dbDelta-compatible, skip cart templates during REST, and prevent migration cron fatals when Tutor tables are missing per blog. --- UPSTREAM_FIXES.md | 337 +++++++++++++++++++++++++++++ classes/Template.php | 19 +- classes/Tutor.php | 85 ++++---- classes/Upgrader.php | 3 +- helpers/QueryHelper.php | 77 ++++++- helpers/ValidationHelper.php | 6 - migrations/BatchProcessor.php | 28 +++ migrations/ProcessByWcMigrator.php | 19 ++ migrations/QuizAttemptMigrator.php | 19 ++ models/CartModel.php | 20 ++ models/WithdrawModel.php | 2 +- 11 files changed, 554 insertions(+), 61 deletions(-) create mode 100644 UPSTREAM_FIXES.md diff --git a/UPSTREAM_FIXES.md b/UPSTREAM_FIXES.md new file mode 100644 index 0000000000..8ffbde6668 --- /dev/null +++ b/UPSTREAM_FIXES.md @@ -0,0 +1,337 @@ +# Tutor LMS Upstream Fix Notes (v4.0.4) + +Handoff document for Tutor LMS maintainers. + +| Item | Value | +|---|---| +| Plugin version tested | Tutor LMS **4.0.4** | +| WordPress | **7.0.2** Multisite | +| PHP | 7.4+ | +| Example subsite | Blog ID `7`, prefix `wp_7_`, URL `https://esgahead.com` | +| Main DB prefix | `wp_` (base) / site prefix `wp_N_` | + +These changes are a **small upstream patch set**, not a site-specific workaround. Do **not** invent fake `wp_N_users` / `wp_N_usermeta` tables. + +--- + +## Suggested PR order + +1. **Issue 1** — `prepare_table_name()` (+ ValidationHelper / WithdrawModel) + unit tests +2. **Issue 2** — dbDelta schema cleanup in `Tutor::create_database()` + Upgrader +3. **Issue 3** — `BatchProcessor::can_run()` migration guards +4. **Issue 4** — REST `the_content` skip + CartModel soft-fail / lazy create + +Issues 2 and 4 are related (FK-free schemas are required for Multisite table creation). Issues 1 and 3 are independent. + +--- + +## Summary + +| # | Status | Problem | +|---|---|---| +| 1 | Fixed | Blog prefix applied to global `users` / `usermeta` → `wp_7_usermeta`, `wp_7_wp_users` | +| 2 | Fixed | `dbDelta` schemas use unnamed `INDEX`, `--` comments, TEXT defaults, `FOREIGN KEY`s → activation SQL errors; Multisite FK **name** collisions | +| 3 | Fixed | Cron `quiz_attempt_migrator` fatals on blogs without `wp_N_tutor_quiz_attempts` | +| 4 | Fixed | Site Editor Patterns REST loads cart/checkout → queries missing `wp_N_tutor_carts` | + +--- + +## Issue 1 — Multisite-safe table name resolution + +### Root cause + +`Tutor\Helpers\QueryHelper::prepare_table_name()` treated every table as blog-scoped: + +```php +// BEFORE +$table_prefix = self::get_table_prefix(); // $wpdb->prefix, e.g. wp_7_ +if ( strpos( $table_name, $table_prefix ) !== 0 ) { + $table_name = $table_prefix . $table_name; +} +``` + +| Input | Wrong (blog 7) | Correct | +|---|---|---| +| `usermeta` | `wp_7_usermeta` | `wp_usermeta` | +| `$wpdb->users` (`wp_users`) | `wp_7_wp_users` | `wp_users` | +| `users u` / `users AS u` | `wp_7_users u` | `wp_users u` | +| `tutor_orders` / `tutor_orders o` | `wp_7_tutor_orders` | OK | +| Already `wp_7_tutor_orders` | OK | OK | + +Affected call paths (fix **centrally** — do not patch each site): + +- `classes/Utils.php` → `QueryHelper::get_count( 'usermeta', … )` +- `models/OrderModel.php` → join `"{$wpdb->users} u"` +- `models/WithdrawModel.php` / `UserModel.php` → users joins +- `restapi/RestAuth.php`, tools token UI → `$wpdb->usermeta` + +### Fix — `helpers/QueryHelper.php` + +Rewrite `prepare_table_name( string $table_name )`: + +1. `trim`; split optional alias with `/^(\S+)(\s+(?:AS\s+)?\S+)$/i`; keep alias text exact. +2. Validate base as `[A-Za-z0-9_]+`; if invalid, return original string. +3. If base starts with `$wpdb->prefix` → keep. +4. If base ∈ `$wpdb->global_tables` (and Multisite `$wpdb->ms_global_tables`) → `$wpdb->{$name}` or `$wpdb->base_prefix . $name`. +5. If base equals a known global full name → keep. +6. If base starts with `$wpdb->base_prefix` → keep (no double-prefix). +7. Else prepend `$wpdb->prefix`. +8. Reattach alias. + +Must use `$wpdb` properties (custom prefixes). Single-site must stay: `users` → `{prefix}users`, `tutor_orders` → `{prefix}tutor_orders`. + +### Related call sites + +| File | Change | +|---|---| +| `helpers/ValidationHelper.php` | `has_record()`: drop local `$wpdb->prefix` prepend; pass table to `QueryHelper::get_row()` only. | +| `models/WithdrawModel.php` | `FROM {$wpdb->prefix}users` → `FROM {$wpdb->users}`. | +| `classes/Tutor.php` | Prefer `$wpdb->users` over `$wpdb->prefix . 'users'` where users are referenced. *(FK lines later removed in Issue 2.)* | + +### Unit tests (recommended) + +| Path | Purpose | +|---|---| +| `tests/unit/QueryHelperPrepareTableNameTest.php` | Multisite + single-site + aliases + custom prefix | +| `tests/bootstrap.php` | Stub `$wpdb` + `is_multisite()` | +| `phpunit.xml.dist` | Suite config | +| `composer.json` | Optional `phpunit/phpunit` require-dev | + +Assert never: `wp_7_users`, `wp_7_usermeta`, `wp_7_wp_users`, `wp_7_wp_usermeta`. + +--- + +## Issue 2 — dbDelta-compatible CREATE TABLE schemas + +### Root cause + +`dbDelta()` mishandles patterns in `Tutor::create_database()`: + +| Symptom | Cause | +|---|---| +| `ADD KEY `` (`course_id`)` | Unnamed `INDEX (course_id)` | +| `CHANGE COLUMN … -- comment` syntax error | Inline `--` comments on columns | +| TEXT/BLOB can't have default | `answer_explanation longtext DEFAULT ''` | +| `ADD COLUMN CONSTRAINT fk_…` | `CONSTRAINT … FOREIGN KEY` in CREATE TABLE | +| Multisite: `Duplicate foreign key constraint name 'fk_tutor_cart_user_id'` | InnoDB FK **names are unique per database**; main site `wp_tutor_carts` blocks `wp_7_tutor_carts` with the same constraint name → **subsite tables fail to create** (feeds Issue 4) | + +### Fix — `classes/Tutor.php` (`create_database()`) + +1. `INDEX (col)` → `KEY col (col)` (quiz attempts, earnings, …). +2. Remove all inline `-- …` comments from CREATE TABLE lines (orders, order items, coupons, …). Keep `COMMENT '…'` if needed. +3. `answer_explanation longtext DEFAULT ''` → `answer_explanation longtext`. +4. `method_data text DEFAULT NULL` → `method_data text`. +5. **Remove all** `CONSTRAINT … FOREIGN KEY …` from dbDelta schemas. Keep `KEY` indexes. Enforce relations in PHP if needed. + +Remove FK constraints from: + +| Table | Constraint name(s) | +|---|---| +| `tutor_ordermeta` | `fk_tutor_ordermeta_order_id` | +| `tutor_order_items` | `fk_tutor_order_item_order_id` | +| `tutor_coupon_applications` | `fk_tutor_coupon_application_coupon_code` | +| `tutor_coupon_usages` | `fk_tutor_coupon_usage_coupon_code`, `fk_tutor_coupon_usage_user_id` | +| `tutor_carts` | `fk_tutor_cart_user_id` | +| `tutor_cart_items` | `fk_tutor_cart_item_cart_id`, `fk_tutor_cart_item_course_id` | + +### Fix — `classes/Upgrader.php` (`upgrade_to_3_8_0()`) + +Remove `CONSTRAINT fk_tutor_itemmeta FOREIGN KEY …` from `tutor_order_itemmeta` CREATE TABLE. + +### Recovery after deploy + +```bash +wp --url= eval 'TUTOR\Tutor::create_database();' --allow-root +``` + +--- + +## Issue 3 — Migration cron fatal without Tutor tables + +### Root cause + +Network-activated Tutor loads on every blog. `migrations/Migration.php` schedules unfinished migrators. Cron: + +```text +wp-cron.php + → quiz_attempt_migrator + → BatchProcessor::process_batch + → QuizAttemptMigrator::get_total_items + → QueryHelper::get_count( 'tutor_quiz_attempts' ) +``` + +On blogs without tables (e.g. blog 6): + +```text +Table 'wordpress.wp_6_tutor_quiz_attempts' doesn't exist +Uncaught Exception in QueryHelper.php (get_count) +``` + +### Fix + +**`migrations/BatchProcessor.php`** + +```php +protected function can_run(): bool { + return true; +} + +public function schedule() { + if ( ! $this->can_run() ) { + return; + } + // existing schedule logic… +} + +public function process_batch() { + if ( ! $this->can_run() ) { + return; // do NOT mark complete — tables may be created later + } + // existing process logic… +} +``` + +**`migrations/QuizAttemptMigrator.php`** + +```php +protected function can_run(): bool { + return QueryHelper::table_exists( 'tutor_quiz_attempts' ); +} +``` + +Also guard `get_total_items()` / `get_items()` with `can_run()` (return `0` / `array()`). + +**`migrations/ProcessByWcMigrator.php`** + +```php +protected function can_run(): bool { + return QueryHelper::table_exists( 'tutor_earnings' ); +} +``` + +Guard `get_total_items()` / `get_items()` similarly. + +### Larger follow-up (optional) + +On network activation, run `create_database()` per blog via `switch_to_blog`, so sites that need Tutor are not left without schema. + +--- + +## Issue 4 — Missing cart tables + Site Editor / Patterns REST + +### Root cause + +1. Tables never created on the subsite because of Issue 2 FK name collision (`fk_tutor_cart_user_id`). +2. Site Editor Patterns REST applies `the_content` → `Template::convert_static_page_to_template` → cart/checkout → `CartModel::get_cart_items` → missing `wp_N_tutor_carts`. + +`wp_N_tutor_carts` is the **correct** Multisite table name; this is not a prefix bug. + +### Fix — `classes/Template.php` + +In `convert_static_page_to_template()`, after the existing `wp_head` guard: + +```php +if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { + return $content; +} +``` + +### Fix — `models/CartModel.php` + +In `get_cart_items()`, before querying: + +```php +static $attempted_create_database = false; +if ( ! QueryHelper::table_exists( 'tutor_carts' ) ) { + if ( ! $attempted_create_database ) { + $attempted_create_database = true; + \TUTOR\Tutor::create_database(); + } + if ( ! QueryHelper::table_exists( 'tutor_carts' ) ) { + return $is_details ? $cart_data : $cart_data['courses']['results']; + } +} +``` + +Requires Issue 2 (FK-free schemas) so lazy `create_database()` can succeed on Multisite. + +--- + +## Files checklist + +| File | Issues | Change | +|---|---|---| +| `helpers/QueryHelper.php` | 1 | Multisite-safe `prepare_table_name()` | +| `helpers/ValidationHelper.php` | 1 | `has_record()` via QueryHelper only | +| `models/WithdrawModel.php` | 1 | `{$wpdb->users}` in raw SQL | +| `classes/Tutor.php` | 1–2 | dbDelta-safe schemas; no FKs / unnamed INDEX / `--` / TEXT defaults | +| `classes/Upgrader.php` | 2 | Remove FK from `order_itemmeta` | +| `migrations/BatchProcessor.php` | 3 | `can_run()` gate | +| `migrations/QuizAttemptMigrator.php` | 3 | Require `tutor_quiz_attempts` | +| `migrations/ProcessByWcMigrator.php` | 3 | Require `tutor_earnings` | +| `classes/Template.php` | 4 | Skip page→template conversion on `REST_REQUEST` | +| `models/CartModel.php` | 4 | Lazy create + empty cart if table missing | +| `tests/unit/QueryHelperPrepareTableNameTest.php` | 1 | Unit tests | +| `tests/bootstrap.php` | 1 | Test bootstrap | +| `phpunit.xml.dist` | 1 | PHPUnit config | +| `composer.json` | 1 | Optional PHPUnit require-dev | +| `tests/MULTISITE_MIGRATION_NOTE.md` | 3 | Short note / follow-up | + +--- + +## Verification + +### Issue 1 + +On a Multisite subsite, never query: + +- `wp_N_users`, `wp_N_usermeta`, `wp_N_wp_users`, `wp_N_wp_usermeta` + +Expected shapes: + +```sql +FROM wp_N_tutor_orders o +INNER JOIN wp_users u ON o.user_id = u.ID; + +FROM wp_usermeta …; +``` + +Run unit tests: `phpunit -c phpunit.xml.dist` + +### Issue 2 + +```bash +wp eval 'TUTOR\Tutor::create_database();' --url= --allow-root +``` + +No empty KEY names, no `--` in CHANGE COLUMN, no TEXT default errors, no `ADD COLUMN CONSTRAINT` / duplicate FK names. +`SHOW TABLES LIKE 'wp_N_tutor_carts';` succeeds. + +### Issue 3 + +```bash +wp cron event run quiz_attempt_migrator --url= --allow-root +``` + +No fatal / no `QueryHelper` exception for missing `tutor_quiz_attempts`. +On a blog **with** tables, migrator still schedules and runs. + +### Issue 4 + +1. Create tables (Issue 2 recovery) on the affected blog. +2. Reload Site Editor → Patterns — no `tutor_carts` missing-table errors. +3. Frontend cart page still works when tables exist. + +### Single-site regression + +`users` → `{prefix}users`, Tutor tables still use `$wpdb->prefix`. + +--- + +## Compatibility / non-goals + +- Preserve single-site table-naming behavior. +- Support custom DB prefixes via `$wpdb` (no hardcoded `wp_`). +- Do **not** create fake global user tables per blog. +- Do **not** keep MySQL FKs in dbDelta schemas on Multisite (constraint name collisions). +- Optional follow-up: network-activation per-blog `create_database()` via `switch_to_blog`. diff --git a/classes/Template.php b/classes/Template.php index f7be8b7b0c..a5b6055d2b 100644 --- a/classes/Template.php +++ b/classes/Template.php @@ -310,7 +310,22 @@ public function convert_static_page_to_template( $content ) { return $content; } - $page_id = get_the_ID(); + $page_id = get_the_ID(); + $tutor_cart_page_id = (int) tutor_utils()->get_option( 'tutor_cart_page_id' ); + $tutor_checkout_page_id = (int) tutor_utils()->get_option( 'tutor_checkout_page_id' ); + + /** + * Do not replace page content with Tutor templates during REST responses. + * + * Site Editor / Patterns REST applies `the_content` while preparing posts. + * Running cart/checkout templates there queries ecommerce tables and can + * error when those tables are missing on a Multisite blog. + * + * @since 4.0.5 + */ + if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { + return $content; + } // Dashboard Page. $student_dashboard_page_id = (int) tutor_utils()->get_option( 'tutor_dashboard_page_id' ); @@ -330,12 +345,10 @@ public function convert_static_page_to_template( $content ) { } if ( tutor_utils()->is_monetize_by_tutor() ) { - $tutor_cart_page_id = (int) tutor_utils()->get_option( 'tutor_cart_page_id' ); if ( $page_id === $tutor_cart_page_id ) { return $this->shortcode_obj->tutor_cart_page(); } - $tutor_checkout_page_id = (int) tutor_utils()->get_option( 'tutor_checkout_page_id' ); if ( $page_id === $tutor_checkout_page_id ) { if ( ! apply_filters( 'tutor_should_load_checkout_page', true ) ) { return ''; diff --git a/classes/Tutor.php b/classes/Tutor.php index 2228c4d726..b29add358f 100644 --- a/classes/Tutor.php +++ b/classes/Tutor.php @@ -785,6 +785,13 @@ public static function create_database() { * * @since 1.0.0 */ + /* + * dbDelta-compatible schemas: + * - Use KEY name (col), not unnamed INDEX (col). + * - Do not use inline `--` comments (they break CHANGE COLUMN). + * - Do not set DEFAULT on TEXT/BLOB columns. + * - Do not declare FOREIGN KEY CONSTRAINTs (dbDelta emits invalid ALTER SQL). + */ $quiz_attempts_sql = "CREATE TABLE {$wpdb->prefix}tutor_quiz_attempts ( attempt_id bigint(20) NOT NULL AUTO_INCREMENT, course_id bigint(20) DEFAULT NULL, @@ -803,10 +810,10 @@ public static function create_database() { manually_reviewed_at datetime DEFAULT NULL, result varchar(10) DEFAULT NULL, PRIMARY KEY (attempt_id), - INDEX (course_id), - INDEX (quiz_id), - INDEX (user_id), - INDEX (result) + KEY course_id (course_id), + KEY quiz_id (quiz_id), + KEY user_id (user_id), + KEY result (result) ) $charset_collate;"; $quiz_attempt_answers = "CREATE TABLE {$wpdb->prefix}tutor_quiz_attempt_answers ( @@ -828,7 +835,7 @@ public static function create_database() { quiz_id bigint(20) DEFAULT NULL, question_title text, question_description longtext, - answer_explanation longtext DEFAULT '', + answer_explanation longtext, question_type varchar(50) DEFAULT NULL, question_mark decimal(9,2) DEFAULT NULL, question_settings longtext, @@ -869,17 +876,17 @@ public static function create_database() { process_by varchar(20) DEFAULT NULL, created_at datetime DEFAULT NULL, PRIMARY KEY (earning_id), - INDEX (user_id), - INDEX (course_id), - INDEX (order_id), - INDEX (process_by) + KEY user_id (user_id), + KEY course_id (course_id), + KEY order_id (order_id), + KEY process_by (process_by) ) $charset_collate;"; $withdraw_table = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}tutor_withdraws ( withdraw_id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) DEFAULT NULL, amount decimal(16,2) DEFAULT NULL, - method_data text DEFAULT NULL, + method_data text, status varchar(50) DEFAULT NULL, updated_at datetime DEFAULT NULL, created_at datetime DEFAULT NULL, @@ -888,27 +895,27 @@ public static function create_database() { $orders_table = "CREATE TABLE {$wpdb->prefix}tutor_orders ( id BIGINT(20) UNSIGNED AUTO_INCREMENT, - parent_id BIGINT(20) UNSIGNED DEFAULT 0, -- for subscription order, store subscription record id + parent_id BIGINT(20) UNSIGNED DEFAULT 0, transaction_id VARCHAR(255) COMMENT 'Transaction id from payment gateway', user_id BIGINT(20) UNSIGNED NOT NULL, - order_type VARCHAR(50) NOT NULL, -- single_order, subscription + order_type VARCHAR(50) NOT NULL, order_status VARCHAR(50) NOT NULL, payment_status VARCHAR(50) NOT NULL, - subtotal_price DECIMAL(13, 2) NOT NULL, -- price calculation based on course sale price - pre_tax_price DECIMAL(13, 2) NOT NULL, -- total price before adding tax + subtotal_price DECIMAL(13, 2) NOT NULL, + pre_tax_price DECIMAL(13, 2) NOT NULL, tax_type VARCHAR(50), tax_rate DECIMAL(13, 2) COMMENT 'Tax percentage', tax_amount DECIMAL(13, 2), - total_price DECIMAL(13, 2) NOT NULL, -- final price - net_payment DECIMAL(13, 2) NOT NULL, -- calculated price if any refund is done else same as total_price + total_price DECIMAL(13, 2) NOT NULL, + net_payment DECIMAL(13, 2) NOT NULL, coupon_code VARCHAR(255), coupon_amount DECIMAL(13, 2), discount_type ENUM('percentage', 'flat') DEFAULT NULL, discount_amount DECIMAL(13, 2), discount_reason TEXT, - fees DECIMAL(13, 2), -- payment gateway fees - earnings DECIMAL(13, 2), -- net earning - refund_amount DECIMAL(13, 2), -- Refund amount + fees DECIMAL(13, 2), + earnings DECIMAL(13, 2), + refund_amount DECIMAL(13, 2), payment_method VARCHAR(255), payment_payloads LONGTEXT, note TEXT, @@ -935,37 +942,35 @@ public static function create_database() { updated_by BIGINT(20) UNSIGNED NOT NULL, PRIMARY KEY (id), KEY order_id (order_id), - KEY meta_key (meta_key), - CONSTRAINT fk_tutor_ordermeta_order_id FOREIGN KEY (order_id) REFERENCES {$wpdb->prefix}tutor_orders(id) ON DELETE CASCADE + KEY meta_key (meta_key) ) $charset_collate;"; $order_items_table = "CREATE TABLE {$wpdb->prefix}tutor_order_items ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, order_id BIGINT(20) UNSIGNED NOT NULL, - item_id BIGINT(20) UNSIGNED NOT NULL, -- course id/plan id - regular_price DECIMAL(13, 2) NOT NULL, -- course regular price - sale_price VARCHAR(13) DEFAULT NULL, -- course sale price - discount_price VARCHAR(13) DEFAULT NULL, -- course discount price - coupon_code VARCHAR(255) DEFAULT NULL, -- coupon code + item_id BIGINT(20) UNSIGNED NOT NULL, + regular_price DECIMAL(13, 2) NOT NULL, + sale_price VARCHAR(13) DEFAULT NULL, + discount_price VARCHAR(13) DEFAULT NULL, + coupon_code VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), KEY order_id (order_id), - KEY item_id (item_id), - CONSTRAINT fk_tutor_order_item_order_id FOREIGN KEY (order_id) REFERENCES {$wpdb->prefix}tutor_orders(id) ON DELETE CASCADE + KEY item_id (item_id) ) $charset_collate;"; $coupons_table = "CREATE TABLE {$wpdb->prefix}tutor_coupons ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, coupon_status VARCHAR(50), - coupon_type VARCHAR(100) DEFAULT 'code', -- coupon type 'code' or 'automatic' + coupon_type VARCHAR(100) DEFAULT 'code', coupon_code VARCHAR(50) NOT NULL, coupon_title VARCHAR(255) NOT NULL, coupon_description TEXT, discount_type ENUM('percentage', 'flat') NOT NULL, discount_amount DECIMAL(13, 2) NOT NULL, - applies_to VARCHAR(100) DEFAULT 'all_courses_and_bundles', -- possible values 'all_courses_and_bundles', 'all_courses', 'all_bundles', 'specific_courses', 'specific_bundles', 'specific_category' - total_usage_limit INT(10) UNSIGNED DEFAULT NULL, -- null for unlimited usage - per_user_usage_limit TINYINT(4) UNSIGNED DEFAULT NULL, -- null for unlimited usage - purchase_requirement VARCHAR(50) DEFAULT 'no_minimum', -- possible values 'no_minimum', 'minimum_purchase', 'minimum_quantity' + applies_to VARCHAR(100) DEFAULT 'all_courses_and_bundles', + total_usage_limit INT(10) UNSIGNED DEFAULT NULL, + per_user_usage_limit TINYINT(4) UNSIGNED DEFAULT NULL, + purchase_requirement VARCHAR(50) DEFAULT 'no_minimum', purchase_requirement_value DECIMAL(13, 2), start_date_gmt DATETIME NOT NULL, expire_date_gmt DATETIME DEFAULT NULL, @@ -983,8 +988,7 @@ public static function create_database() { coupon_code VARCHAR(50) NOT NULL, reference_id BIGINT(20) UNSIGNED NOT NULL, KEY coupon_code (coupon_code), - KEY reference_id (reference_id), - CONSTRAINT fk_tutor_coupon_application_coupon_code FOREIGN KEY (coupon_code) REFERENCES {$wpdb->prefix}tutor_coupons(coupon_code) ON DELETE CASCADE + KEY reference_id (reference_id) ) $charset_collate;"; $coupon_usage_table = "CREATE TABLE {$wpdb->prefix}tutor_coupon_usages ( @@ -993,9 +997,7 @@ public static function create_database() { user_id BIGINT(20) UNSIGNED NOT NULL, PRIMARY KEY (id), KEY coupon_code (coupon_code), - KEY user_id (user_id), - CONSTRAINT fk_tutor_coupon_usage_coupon_code FOREIGN KEY (coupon_code) REFERENCES {$wpdb->prefix}tutor_coupons(coupon_code) ON DELETE CASCADE, - CONSTRAINT fk_tutor_coupon_usage_user_id FOREIGN KEY (user_id) REFERENCES {$wpdb->prefix}users(ID) ON DELETE CASCADE + KEY user_id (user_id) ) $charset_collate;"; $cart_table = "CREATE TABLE {$wpdb->prefix}tutor_carts ( @@ -1006,8 +1008,7 @@ public static function create_database() { updated_at_gmt DATETIME, PRIMARY KEY (id), KEY user_id (user_id), - KEY coupon_code (coupon_code), - CONSTRAINT fk_tutor_cart_user_id FOREIGN KEY (user_id) REFERENCES {$wpdb->prefix}users(ID) ON DELETE CASCADE + KEY coupon_code (coupon_code) ) $charset_collate;"; $cart_items_table = "CREATE TABLE {$wpdb->prefix}tutor_cart_items ( @@ -1016,9 +1017,7 @@ public static function create_database() { course_id BIGINT(20) UNSIGNED NOT NULL, PRIMARY KEY (id), KEY cart_id (cart_id), - KEY course_id (course_id), - CONSTRAINT fk_tutor_cart_item_cart_id FOREIGN KEY (cart_id) REFERENCES {$wpdb->prefix}tutor_carts(id) ON DELETE CASCADE, - CONSTRAINT fk_tutor_cart_item_course_id FOREIGN KEY (course_id) REFERENCES {$wpdb->prefix}posts(ID) ON DELETE CASCADE + KEY course_id (course_id) ) $charset_collate;"; $customer_table = "CREATE TABLE {$wpdb->prefix}tutor_customers ( diff --git a/classes/Upgrader.php b/classes/Upgrader.php index 37781f0769..98e41f541d 100644 --- a/classes/Upgrader.php +++ b/classes/Upgrader.php @@ -271,8 +271,7 @@ public function upgrade_to_3_8_0() { meta_value LONGTEXT NOT NULL, PRIMARY KEY (id), KEY item_id (item_id), - KEY meta_key (meta_key), - CONSTRAINT fk_tutor_itemmeta FOREIGN KEY (item_id) REFERENCES {$wpdb->prefix}tutor_order_items(id) ON DELETE CASCADE + KEY meta_key (meta_key) ) $charset_collate;"; dbDelta( $item_meta_table ); } diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 41b7e4d8f1..1d1a0d5b61 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -1277,21 +1277,86 @@ public static function get_table_prefix() { } /** - * Prepare table name with prefix. + * Prepare table name with the correct WordPress prefix. + * + * Resolves blog-scoped tables with `$wpdb->prefix` and WordPress global + * tables (e.g. users, usermeta) with `$wpdb->base_prefix` so Multisite + * subsites do not produce invalid names like `wp_7_users` or `wp_7_wp_users`. + * Optional SQL aliases are preserved unchanged. * * @since 3.7.0 + * @since 4.0.5 Multisite-safe global vs blog table resolution. * - * @param string $table_name table name. + * @param string $table_name Table name, optionally with an alias (e.g. `users u`, `users AS u`). * * @return string */ public static function prepare_table_name( string $table_name ) { - $table_prefix = self::get_table_prefix(); - if ( strpos( $table_name,$table_prefix ) !== 0 ) { - $table_name = $table_prefix . $table_name; + global $wpdb; + + $table_name = trim( $table_name ); + if ( '' === $table_name ) { + return $table_name; + } + + $alias = ''; + $base = $table_name; + + // Split optional alias: "users u", "users AS u", "wp_users AS user_tbl". + if ( preg_match( '/^(\S+)(\s+(?:AS\s+)?\S+)$/i', $table_name, $matches ) ) { + $base = $matches[1]; + $alias = $matches[2]; + } + + // Only resolve safe SQL identifiers; leave unexpected input unchanged. + if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $base ) ) { + return $table_name; + } + + $blog_prefix = $wpdb->prefix; + $base_prefix = $wpdb->base_prefix; + + // Already qualified with the current blog prefix. + if ( 0 === strpos( $base, $blog_prefix ) ) { + return $base . $alias; + } + + $global_tables = isset( $wpdb->global_tables ) && is_array( $wpdb->global_tables ) + ? $wpdb->global_tables + : array( 'users', 'usermeta' ); + + if ( is_multisite() && isset( $wpdb->ms_global_tables ) && is_array( $wpdb->ms_global_tables ) ) { + $global_tables = array_merge( $global_tables, $wpdb->ms_global_tables ); + } + + $global_tables = array_unique( $global_tables ); + + // Unprefixed WordPress global table (e.g. "users", "usermeta"). + if ( in_array( $base, $global_tables, true ) ) { + if ( isset( $wpdb->$base ) && is_string( $wpdb->$base ) && '' !== $wpdb->$base ) { + return $wpdb->$base . $alias; + } + + return $base_prefix . $base . $alias; + } + + // Already a fully qualified global table name (e.g. $wpdb->users). + foreach ( $global_tables as $global_table ) { + $qualified = ( isset( $wpdb->$global_table ) && is_string( $wpdb->$global_table ) && '' !== $wpdb->$global_table ) + ? $wpdb->$global_table + : $base_prefix . $global_table; + + if ( $base === $qualified ) { + return $base . $alias; + } + } + + // Already qualified with the network/base prefix (avoid double-prefixing). + if ( 0 === strpos( $base, $base_prefix ) ) { + return $base . $alias; } - return $table_name; + return $blog_prefix . $base . $alias; } /** diff --git a/helpers/ValidationHelper.php b/helpers/ValidationHelper.php index 178476db1f..b0612279ea 100644 --- a/helpers/ValidationHelper.php +++ b/helpers/ValidationHelper.php @@ -354,12 +354,6 @@ public static function is_user_exists( int $user_id ): bool { * @return boolean */ public static function has_record( $table, $column, $value ) { - global $wpdb; - $table_prefix = $wpdb->prefix; - if ( strpos( $table, $table_prefix ) !== 0 ) { - $table = $table_prefix . $table; - } - $record = QueryHelper::get_row( $table, array( $column => $value ), $column ); return $record ? true : false; } diff --git a/migrations/BatchProcessor.php b/migrations/BatchProcessor.php index ea06da5f93..e43d6ad607 100644 --- a/migrations/BatchProcessor.php +++ b/migrations/BatchProcessor.php @@ -126,6 +126,21 @@ abstract protected function get_items( $offset, $limit) : array; */ abstract protected function get_total_items() : int; + /** + * Whether this batch processor can safely run on the current site. + * + * Override in child classes to require Tutor tables, etc. Used to avoid + * fatal errors on Multisite blogs where Tutor was network-activated but + * site tables were never created. + * + * @since 4.0.5 + * + * @return bool + */ + protected function can_run(): bool { + return true; + } + /** * Schedule the batch processing. * @@ -137,6 +152,10 @@ abstract protected function get_total_items() : int; * @return void */ public function schedule() { + if ( ! $this->can_run() ) { + return; + } + if ( ! wp_next_scheduled( $this->action ) ) { wp_schedule_single_event( time() + $this->schedule_interval, $this->action ); } @@ -155,6 +174,15 @@ public function schedule() { * @throws \Exception If not implemented any interface on child class.. */ public function process_batch() { + /** + * Skip safely when required tables are missing (e.g. Multisite blog without + * Tutor initialized). Do not mark complete so migration can run later if + * tables are created. + */ + if ( ! $this->can_run() ) { + return; + } + $progress = get_option( $this->progress_option, array( diff --git a/migrations/ProcessByWcMigrator.php b/migrations/ProcessByWcMigrator.php index b494845e4f..5a6e742227 100644 --- a/migrations/ProcessByWcMigrator.php +++ b/migrations/ProcessByWcMigrator.php @@ -57,6 +57,17 @@ class ProcessByWcMigrator extends BatchProcessor implements BulkProcessor { */ protected $schedule_interval = 10; + /** + * Only run when Tutor earnings tables exist for this blog. + * + * @since 4.0.5 + * + * @return bool + */ + protected function can_run(): bool { + return QueryHelper::table_exists( 'tutor_earnings' ); + } + /** * Get the total count of the data to be processed * @@ -65,6 +76,10 @@ class ProcessByWcMigrator extends BatchProcessor implements BulkProcessor { * @return int */ protected function get_total_items() : int { + if ( ! $this->can_run() ) { + return 0; + } + $primary_table = 'tutor_earnings te'; $joining_tables = array( array( @@ -101,6 +116,10 @@ protected function get_total_items() : int { * @return array */ protected function get_items( $offset, $limit ) : array { + if ( ! $this->can_run() ) { + return array(); + } + $primary_table = 'tutor_earnings te'; $joining_tables = array( array( diff --git a/migrations/QuizAttemptMigrator.php b/migrations/QuizAttemptMigrator.php index 59d5aff9e2..c870452b47 100644 --- a/migrations/QuizAttemptMigrator.php +++ b/migrations/QuizAttemptMigrator.php @@ -56,6 +56,17 @@ class QuizAttemptMigrator extends BatchProcessor implements SingleProcessor { */ protected $schedule_interval = 10; + /** + * Only run when Tutor quiz attempt tables exist for this blog. + * + * @since 4.0.5 + * + * @return bool + */ + protected function can_run(): bool { + return QueryHelper::table_exists( 'tutor_quiz_attempts' ); + } + /** * Get total unprocessed result. * @@ -64,6 +75,10 @@ class QuizAttemptMigrator extends BatchProcessor implements SingleProcessor { * @return int */ protected function get_total_items(): int { + if ( ! $this->can_run() ) { + return 0; + } + return QueryHelper::get_count( 'tutor_quiz_attempts', array( 'result' => array( 'IS', 'NULL' ) ), array(), 'attempt_id' ); } @@ -78,6 +93,10 @@ protected function get_total_items(): int { * @return array */ protected function get_items( $offset, $limit ) : array { + if ( ! $this->can_run() ) { + return array(); + } + global $wpdb; return $wpdb->get_results( $wpdb->prepare( diff --git a/models/CartModel.php b/models/CartModel.php index c011e3914a..097d91a21d 100644 --- a/models/CartModel.php +++ b/models/CartModel.php @@ -117,6 +117,26 @@ public function get_cart_items( $user_id, $is_details = true ) { ), ); + /** + * On Multisite, ecommerce tables may be missing if create_database previously + * failed (e.g. duplicate global FOREIGN KEY names across blogs). Attempt once + * to create them, then soft-fail with an empty cart if still absent. + * + * @since 4.0.5 + */ + static $attempted_create_database = false; + if ( ! QueryHelper::table_exists( 'tutor_carts' ) ) { + if ( ! $attempted_create_database ) { + $attempted_create_database = true; + \TUTOR\Tutor::create_database(); + } + + if ( ! QueryHelper::table_exists( 'tutor_carts' ) ) { + TutorCache::set( $cache_key, $is_details ? $cart_data : $cart_data['courses']['results'] ); + return $is_details ? $cart_data : $cart_data['courses']['results']; + } + } + $user_cart = QueryHelper::get_row( 'tutor_carts', array( diff --git a/models/WithdrawModel.php b/models/WithdrawModel.php index 1d9e53bff8..4664f6ad88 100644 --- a/models/WithdrawModel.php +++ b/models/WithdrawModel.php @@ -195,7 +195,7 @@ public static function get_withdraw_summary( $instructor_id, $args = array() ) { HAVING user_id = u.ID ),0) total_matured - FROM {$wpdb->prefix}users u WHERE u.ID=%d + FROM {$wpdb->users} u WHERE u.ID=%d ) a", 'completed',